{"index":21,"slug":"editorial-2027-06-practice-performance-capstone","title":"Медленная первая загрузка: отделяем TTFB от блокирующих ресурсов","excerpt":"Как разобрать пустой первый экран на измеряемые участки: ожидание сервера, передача HTML и блокировка CSS или JavaScript.","contentHtml":"
Пользователь открывает страницу и несколько секунд видит пустой экран. В отчёте появляется одна цифра: «загрузка заняла 2,4 секунды». Этой цифры недостаточно для решения. 1,6 секунды могли уйти до первого байта HTML, а могли — на выполнение скрипта после ответа сервера. Цена ошибки — менять JavaScript, когда тормозит backend, или добавлять кеш, когда браузер ждёт блокирующий CSS.
Тезис статьи простой: сначала разделите критический путь на участки, затем меняйте один участок и повторяйте тот же замер. TTFB (time to first byte) показывает ожидание первого байта ответа. Он не показывает время до готового экрана. Передача HTML, CSS, JavaScript и работа главного потока требуют отдельных наблюдений.
Браузер начинает навигацию с запроса. Сервер формирует ответ и отправляет первый байт. Только после этого браузер получает весь HTML и строит DOM. Когда parser встречает таблицу стилей, он ждёт CSSOM для расчёта стилей. Синхронный script может остановить parser. Отложенный код продолжает занимать главный поток даже после завершения сетевой загрузки.
Поэтому время нужно разложить. Участок до первого байта относится к сети, proxy и серверному обработчику. Участок от первого байта до конца ответа относится к размеру HTML и передаче. Ранний CSS влияет на построение стилей. Скрипт может задержать DOM, layout или обработку пользовательского ввода. Одна общая длительность скрывает владельца и действие.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| TTFB стабильно выше 300 мс | Сервер или зависимость задерживает начало ответа | Сравнить responseStart - requestStart и журнал handler | Профилировать серверный путь; не начинать с bundle |
| TTFB нормален, HTML приходит долго | Большой ответ или медленная передача | Сравнить responseEnd - responseStart и размер HTML | Уменьшить ответ, проверить сжатие и кеш |
| HTML пришёл, первый экран ждёт CSS | Ранний stylesheet блокирует построение стилей | Сверить waterfall, initiator и видимый результат | Сократить критический CSS или отложить второстепенный |
| Сеть закончилась, экран не готов | Долгая задача на главном потоке | Посмотреть long tasks и длительность script | Разбить работу или перенести некритичную часть |
| После async ломается интерфейс | Код потерял порядок инициализации | Проверить зависимости скриптов и ошибки консоли | Вернуть порядок или использовать defer, если он подходит |
| Одно измерение лучше остальных | Сработал кеш или изменились сеть и устройство | Повторить серию и сравнить p50/p95 | Не принимать единичный прогон за результат |
Ниже — минимальный локальный сервер. Параметр mode=slow добавляет задержку перед отправкой заголовков. Пример намеренно проверяет только серверный участок. Он не моделирует мобильную сеть, кеш браузера, CDN, рендеринг или реальную нагрузку. Его вывод нельзя выдавать за production-результат.
import { createServer } from 'node:http'; import { performance } from 'node:perf_hooks'; const server = createServer((request, response) => { const url = new URL(request.url, 'http://127.0.0.1'); const delay = url.searchParams.get('mode') === 'slow' ? 300 : 0; setTimeout(() => { response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); response.end('<main>ready</main>'); }, delay); }); server.listen({ host: '127.0.0.1', port: 0 }, async () => { const { port } = server.address(); for (const mode of ['fast', 'slow']) { const started = performance.now(); const response = await fetch('http://127.0.0.1:' + port + '/?mode=' + mode); await response.text(); console.log(mode, Math.round(performance.now() - started), response.status); } server.close(); });Ожидаемый результат — две строки со статусом 200. Строка slow должна быть примерно на 300 миллисекунд длиннее. Точное значение зависит от машины, поэтому сравнивайте режимы в одном запуске. Если различия нет, проверьте URL, единицы времени и то, что задержка стоит до writeHead, а не после отправки ответа.
Этот пример показывает причинность только для TTFB. Клиент ждёт тело целиком, поэтому его общее время включает передачу HTML. Чтобы измерить участки в браузере, откройте ту же страницу и прочитайте navigation entry. Не переносите число из Node в вывод о FCP или LCP: серверный пример не видит отрисовку.
В браузере найдите запись типа navigation. Поля requestStart, responseStart и responseEnd дают точки для разделения запроса, первого байта и конца ответа. Поле domContentLoadedEventEnd показывает завершение соответствующего события. Это диагностические временные точки, а не готовая оценка качества экрана.
const navigation = performance.getEntriesByType('navigation')[0]; if (navigation) { console.table({ ttfb: navigation.responseStart - navigation.requestStart, html: navigation.responseEnd - navigation.responseStart, domContentLoaded: navigation.domContentLoadedEventEnd - navigation.startTime }); }Если responseStart - requestStart велик, ищите серверную задержку, соединение и proxy. Если TTFB мал, а responseEnd - responseStart велик, проверьте размер HTML, сжатие и сеть. Если оба участка малы, но пользователь всё ещё видит пустой экран, переходите к ресурсам и главному потоку. Такой отрицательный путь важен: отсутствие серверной проблемы не доказывает, что страница быстрая.
DOMContentLoaded нельзя называть временем готовности экрана. Событие связано с разбором документа и отложенными скриптами. Изображения, шрифты, layout, paint и работа JavaScript могут продолжаться. Для визуального симптома нужен отдельный наблюдаемый критерий. Если команда использует FCP или LCP, измеряйте его тем же браузерным сценарием и не подменяйте его TTFB.
После navigation entry соберите записи ресурсов. Для каждой записи важны URL без секретных параметров, тип ресурса, время начала, конец ответа, initiator и размер. Сначала ищите ресурс, который действительно пересекается с критическим участком. Длинная полоса в waterfall сама по себе не доказывает блокировку: шрифт мог начать загрузку после отрисовки главного содержимого.
Обычный stylesheet влияет на построение стилей. Синхронный script может остановить parser. defer оставляет порядок отложенных скриптов и запускает их после разбора документа. async запускает скрипт по готовности и меняет порядок. Если второй файл использует глобальный объект первого, безоговорочная замена на async создаёт отрицательный путь: сеть стала быстрее, но приложение упало до инициализации.
Поле renderBlockingStatus полезно только при поддержке конкретного браузера и конкретной записи. Отсутствующее значение означает отсутствие данных, а не доказательство, что ресурс не блокирует. Сверяйте API с HTML и визуальным результатом. Если сведения не совпадают, оставьте это ограничение в отчёте и опирайтесь на поддерживаемые наблюдения.
| Наблюдение | Первое изменение | Риск |
|---|---|---|
| Высокий TTFB при разных клиентах | Профилировать handler и его зависимости | Изменение кеша скроет, но не устранит причину |
| Большой HTML при нормальном TTFB | Проверить структуру ответа и сжатие | Сложнее кеширование и отладка шаблона |
| CSS задерживает первый полезный контент | Отделить критические стили от второстепенных | FOUC и рассинхрон стилей |
| Script создаёт длинную задачу | Разбить вычисление или отложить его | Изменится порядок состояния и событий |
Navigation Timing описывает события навигации, но не знает архитектуру backend и не устанавливает пороги качества. Resource Timing может скрывать часть сведений из-за политики приватности и кросс-доменных ограничений. Браузеры различаются по поддержке отдельных полей. Поэтому один API не заменяет сетевой журнал, профиль главного потока и визуальный замер.
Локальный сервер с фиксированной задержкой проверяет ветвление диагностики, но не показывает распределение latency, холодный кеш, CDN, TLS, балансировщик и конкуренцию запросов. Числа из примера не являются обещанием для production. Полевой вывод требует зафиксированных условий и серии наблюдений на целевом устройстве.
Ускорение одного участка может ухудшить другой. Отложенный CSS уменьшает блокировку, но может вызвать вспышку нестилизованного контента. Разбиение JavaScript сокращает длинную задачу, но увеличивает количество границ состояния. Проверяйте не только сеть, но и визуальную стабильность, ошибки консоли и интерактивность.
Диагностика готова, если для страницы есть серия повторяемых замеров, отдельные значения TTFB и передачи HTML, список ранних ресурсов с initiator и запись о главном потоке. Для выбранной гипотезы названо одно действие, а повторный прогон показывает изменение именно целевого участка. Первый экран не ухудшился, консоль не получила новую ошибку, а отрицательный путь проверен на медленной сети. Если команда может сказать только «страница стала быстрее», причина и критерий ещё не определены.