DarkRiDDeR14 мин

Под капотом первой загрузки: где теряется время между HTML и полезным экраном

JavaScriptПроизводительностьБраузер

Симптом выглядит противоречиво: backend показывает короткое время ответа, Network не содержит гигабайтных файлов, а пользователь всё равно ждёт пустой или нерабочий первый экран. Ошибка расследования в том, что серверный ответ принимают за завершение загрузки. Браузер после первого байта ещё должен разобрать HTML, обнаружить зависимости, получить стили, выполнить синхронный код, построить дерево рендера, декодировать нужные изображения и выделить время на paint. Быстрый origin закрывает только один участок этой цепочки. Цена ошибки — оптимизировать сервер и оставить пользователя перед пустым экраном.

Здесь не нужен мифический «браузер тормозит». Нужна модель зависимостей. Одни ресурсы можно качать параллельно, но некоторые работы ждут предыдущей границы: нельзя применить внешний stylesheet до его прихода; JavaScript без defer может остановить разбор HTML; картинка, добавленная только после выполнения приложения, не будет обнаружена preload scanner из начального документа. Критический путь — не список всех файлов, а цепочка того, без чего выбранный полезный экран не может появиться.

Документ задаёт не только разметку, но и момент обнаружения

HTML приходит потоково. Пока браузер читает начальный документ, он может обнаружить link, script, img и начать работу с ними раньше, чем весь ответ будет получен. Поэтому важен не только размер HTML, но и место, где расположен критический URL. Если hero-изображение или основной stylesheet скрыт за JavaScript-конфигурацией, браузер узнает о нём только после новой работы; лишняя задержка возникает до реальной загрузки байтов.

Это не аргумент за то, чтобы сделать весь HTML огромным. Начальный ответ должен содержать то, что позволяет браузеру увидеть и запросить первый экран: семантический каркас, нужный CSS и прямой адрес критического изображения или шрифта, если он действительно нужен. Второстепенные карточки, рекламные виджеты и модальные окна могут быть отложены. Решение принимают по роли на первом экране, а не по тому, какой компонент проще перенести в шаблон.

ГраницаЧто открывает работуТипичная ошибкаКак проверить
Ответ HTMLПарсер и preload scanner видят URL из начальной разметкиКритический URL появляется только после boot приложенияПосмотреть документ и момент старта ресурса на waterfall
CSSСтиль становится доступен для расчёта и рендераСчитать stylesheet обычной второстепенной картинкойСопоставить окончание CSS с моментом первого полезного paint
JavaScriptParse, compile, execute и создание DOMСмотреть только gzip-размер чанкаВыделить scripting и функцию в main-thread trace
ИзображениеЗапрос, байты, decode и paintПосле responseEnd считать hero видимымСвязать URL с decode, screenshot и LCP-кандидатом, если метрика доступна

Navigation Timing описывает путь самого документа, а Resource Timing — путь отдельных ресурсов. Эти записи полезны, но не содержат полный причинный граф. Например, высокий responseEnd изображения говорит, когда закончилась передача, но не говорит, что оно было критическим или что его разрешили отрисовать. Поэтому запись из API нужно всегда читать рядом с DOM, сетевым водопадом и trace главного потока.

CSS — часть визуальной готовности, а не украшение после HTML

Для первого экрана CSS определяет, какие элементы видны, какие шрифты и размеры участвуют в layout и может ли браузер собрать корректное дерево рендера. Внешний stylesheet обычно имеет приоритетную роль: пока нет необходимых правил, браузер старается не показывать нестабильный или неверно стилизованный результат. Если приложение выводит разметку, но затем прячет её классом до окончания инициализации, пользователю всё равно: HTML существует, а полезного экрана нет.

Проверка начинается с конкретного stylesheet, а не с общего правила «инлайнить critical CSS». В trace смотрим, был ли CSS завершён до первого screenshot и не идут ли затем длинные style или layout. В Network смотрим, когда браузер обнаружил файл, насколько он конкурирует с другими ранними запросами и нет ли import-цепочки, которая откладывает правила. В DOM смотрим, не создаёт ли JavaScript огромное дерево или не меняет ли классы в несколько проходов. Каждая из этих причин требует другого изменения.

<link rel="stylesheet" href="/assets/app.css">
<link rel="preload" as="image" href="/assets/hero-960.webp" type="image/webp">

<main class="product-page">
  <h1>Название товара</h1>
  <img
    src="/assets/hero-960.webp"
    width="960"
    height="640"
    alt="Товар на нейтральном фоне"
  >
</main>

Этот фрагмент не является рецептом для всех страниц. Он показывает проверяемую идею: критический URL виден в начальном HTML, а размер изображения известен разметке. Preload оправдан только после доказательства, что именно этот ресурс нужен выбранному первому экрану. Добавить его ко всем картинкам — значит забрать пропускную способность у стиля, документа или другого важного ресурса. loading="lazy" у hero также нельзя ставить по привычке: оно намеренно откладывает старт загрузки.

JavaScript создаёт два разных вида задержки

Первый вид — сетевой и поисковый: browser должен обнаружить, запросить и получить скрипт. Второй — вычислительный: после прихода байтов браузер разбирает и выполняет код на главном потоке. Эти этапы имеют разный диагноз. Убрать десять килобайт из чанка полезно, если они были на критическом пути передачи. Но если задержку создаёт синхронная инициализация большого списка, форматирование данных или повторный layout, тот же файл может прийти быстро и всё равно задержать paint.

Особенно опасна инициализация, которая выглядит маленькой в diff: импорт добавляет polyfill, компонент при старте строит сотни строк таблицы, сторонний код измеряет каждый DOM-узел, аналитика синхронно проходит по странице. В Network это может быть один обычный JS-запрос. В trace будет длинная работа на main thread, иногда с несколькими зелёными и фиолетовыми участками style/layout после неё. Пока функция не названа, правило «сделаем code split» остаётся предположением.

Наблюдение в traceРабочая гипотезаНеобязательный выводПроверяемое действие
Длинный scripting сразу после app.jsКритический код выполняет лишнюю работу до первого экранаЧто весь app.js надо вынести в отдельный чанкНайти функцию, отложить второстепенный путь и повторить тот же профиль
Несколько style/layout после одного обработчикаКод чередует чтение геометрии и запись классовЧто CSS-файл слишком большойСгруппировать измерения и изменения DOM, проверить число layout-проходов
Пустой экран до завершения JSРазметка или критический ресурс создаётся только приложениемЧто сервер обязан немедленно перейти на новый стекВывести минимальный каркас и критический URL раньше либо доказать иной путь
JS пришёл поздноРесурс поздно обнаружен или конкурирует в сетиЧто выполнение кода дорогоеСравнить startTime скрипта с HTML и приоритетом на waterfall

Изображение имеет жизнь после responseEnd

У изображения есть размер на диске, фактические пиксели, место в layout, момент декодирования и момент paint. Сетевой водопад честно покажет transfer и responseEnd, но пользователь увидит файл позже, если браузер занят скриптом, ждёт нужный стиль или декодирует слишком большое изображение. У hero также важен выбор варианта: нет смысла передавать desktop-оригинал на маленький экран, если разметка знает реальный размер контейнера.

Не следует объявлять каждую картинку LCP-кандидатом. Сначала на выбранном первом экране определяем, какой визуальный элемент действительно самый крупный и полезный. В современных инструментах это можно сопоставить с LCP, но статья не превращает этот термин в фальшивый замер 2019 года. Если доступен только screenshot, пишем честнее: «проверяем появление hero-изображения» и сохраняем условия. Если есть trace и metric marker, прикладываем его к конкретному URL.

Критическая цепочка первой загрузки: HTML открывает обнаружение CSS, JavaScript и hero-изображения; стили и свободный main thread нужны до полезного paint
Запросы могут идти параллельно, но полезный экран ждёт зависимые границы: обнаружение, нужный ресурс, доступный main thread и paint.

Четыре временных слоя нельзя заменить одной суммой

Полезно держать четыре вопроса. Первый: когда браузер получил первый байт HTML? Второй: когда начались и закончились критические запросы? Третий: чем был занят main thread между приходом ресурсов и первым полезным paint? Четвёртый: какой элемент на экране ещё ждал decode, стиль или layout? Даже если все числа сохранены в миллисекундах, они не образуют последовательность без перекрытий. Сложение длительностей может показать 900 ms там, где реальное окно загрузки 500 ms, и направить усилия в неверный участок.

Позднейшая разбивка LCP на TTFB, resource load delay, resource load duration и element render delay удобна как проверка полноты вопросов. Она не отменяет различий браузеров, не доказывает причину сама по себе и не позволяет пересчитать пользовательский опыт по одному тёплому запуску. Её ценность здесь практическая: если после сокращения байтов изображение не появилось раньше, проверяем render delay, а не повторяем ту же оптимизацию сильнее.

performance.mark("catalog: render-start");
renderCatalog(shellData);
performance.mark("catalog: render-end");
performance.measure("catalog: initial-render",
  "catalog: render-start",
  "catalog: render-end");

const measures = performance.getEntriesByType("measure");
console.table(measures.map(({ name, duration }) => ({
  name,
  duration: Math.round(duration),
})));

User Timing не заменяет trace, но даёт приложению именованную границу: где начался и закончился его собственный render. Эту метку стоит ставить вокруг конкретной операции, а не вокруг всей загрузки. Иначе она будет включать сеть, таймеры и чужие скрипты, а название initial-render перестанет соответствовать измеряемому участку. На production такие marks требуют отдельного решения о сборе данных и приватности; в локальном профиле они помогают читать дорожку.

Выбираем действие по разрыву в цепочке

Если критическое изображение начинается заметно позже документа, сначала ищем его URL: оно в начальном HTML, в CSS или создаётся кодом? Если CSS завершён поздно, проверяем import-цепочку, объём и конкуренцию, а не переносим весь stylesheet inline. Если до первого полезного screenshot занята основная нить, идём в функцию, DOM или сторонний код. Если ресурс уже пришёл, а элемент не нарисован, проверяем доступность main thread, скрывающий класс, размер контейнера и decode.

Действие должно иметь обратимое доказательство. Например, временно убрать второстепенный виджет из начального пути и повторить профиль. Если scripting ушёл, а полезный блок появился раньше, гипотеза получила опору; затем решение оформляют аккуратно с fallback и проверкой функциональности. Если ничего не изменилось, не держим feature-ветку ради надежды. Возвращаемся к предыдущей границе и смотрим, какой ресурс или работа всё ещё ждёт.

  1. Определить полезный первый экран и назвать один визуальный элемент или интеракцию, которая должна быть готова.
  2. Проследить его назад: нужен ли ему HTML, stylesheet, скрипт, изображение, шрифт или данные.
  3. На waterfall проверить момент обнаружения и завершения каждого критического запроса.
  4. На main thread найти блоки scripting, style, layout, paint между готовностью ресурса и экраном.
  5. Сделать одно минимальное изменение на ранней разорванной границе и повторить условия профиля.
  6. Зафиксировать результат как лабораторное наблюдение; полевая метрика и выпускной бюджет остаются следующей отдельной работой.

Итог: критический путь — это зависимость, а не рейтинг файлов

Сервер, сеть, JavaScript, CSS и картинка не соревнуются за один титул виновника. Они образуют путь, на котором ранняя задержка может спрятать более позднюю, а быстрый файл может ждать занятый main thread. Когда этот путь нарисован, команда перестаёт спорить о «самом тяжёлом» ресурсе и начинает проверять, какая граница действительно удерживает полезный экран.

Практический результат механизма — небольшой словарь для trace: обнаружен поздно, ждёт CSS, занял main thread, ждёт decode, нарисован позже. В следующем разборе этот словарь применяется к учебной карточке: ответ HTML быстрый, но экран остаётся пустым. Сценарий будет явно помечен как controlled fixture, чтобы не выдать иллюстрацию за измерение чужого продукта.

Проверяемые источники

  • MDN: Navigation Timing — объект navigation entry и границы загрузки документа, DOM и обработчиков события загрузки
  • MDN: PerformanceResourceTiming — состав времён и размеров отдельных ресурсов, включая transferSize, encodedBodySize и ограничения кросс-доменных записей
  • MDN: Performance data — типы записей Performance API и смысл developer marks и measures
  • Chrome DevTools: Inspect network activity — разделение запросов по типам, фильтры и проверка водопада без догадки по одному общему времени загрузки
  • Chrome DevTools: Performance features reference — как trace показывает работу loading, scripting, rendering и painting, а также custom marks
  • web.dev: Web Vitals — современная рамка пользовательских метрик; в переиздании она помогает не смешивать скорость отображения с одним сетевым числом
  • web.dev: Optimize Largest Contentful Paint — разделяет TTFB, задержку старта критического ресурса, его загрузку и задержку отрисовки; это позднейшая терминология для проверки гипотезы, а не выданная за отчёт 2019 года