Что происходит от запроса до первого пикселя

Между «браузер получил первый байт HTML» и «пользователь увидел заголовок» лежит вполне конкретная цепочка шагов. Разберём её по кадрам — и станет понятно, что именно её тормозит.

Критический путь рендеринга (Critical Rendering Path) — минимальная последовательность действий браузера, которую нужно полностью завершить, чтобы показать первый кадр страницы: разобрать HTML, разобрать CSS, объединить их, посчитать геометрию и залить пиксели.

Зачем это знать

Метрики Core Web Vitals — LCP, INP, CLS — это не самостоятельные сущности, а следствия. LCP плохой не «сам по себе»: он плохой, потому что где-то в критическом пути стоит ресурс, которого браузер вынужден ждать. Пока вы не видите путь целиком, оптимизация превращается в гадание: сжали картинки — не помогло, включили HTTP/2 — не помогло, а виноват был один синхронный скрипт аналитики в <head>.

Хорошая новость: путь короткий и полностью детерминированный. Пять шагов, и в каждом есть ровно одна причина, по которой браузер может встать.

Пять шагов от байтов до пикселей

  1. DOM. Байты HTML → токены → узлы → дерево документа. Строится инкрементально: браузер не ждёт последнего байта, он парсит по мере поступления.
  2. CSSOM. Все стили → объектная модель со всеми вычисленными правилами. В отличие от DOM, строится «всё или ничего».
  3. Дерево рендеринга (render tree). DOM + CSSOM = только то, что реально видно. Узлы с display: none сюда не попадают (а вот visibility: hidden — попадает, он занимает место).
  4. Layout (reflow). Браузер считает точную геометрию: где и какого размера каждый прямоугольник. До этого шага размеры в CSS — просто числа, не координаты.
  5. Paint и composite. Заливка пикселей по слоям и склейка слоёв. Только теперь пользователь что-то видит.

Первые четыре шага должны пройти до первого кадра. Всё, что удлиняет любой из них, откладывает момент, когда страница перестаёт быть белым экраном.

Рабочий пример: типичная «медленная» страница

<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <title>Каталог</title>
  <link rel="stylesheet" href="/css/main.css">        <!-- 1 -->
  <link rel="stylesheet" href="/css/print.css" media="print">  <!-- 2 -->
  <script src="/js/analytics.js"></script>              <!-- 3 -->
</head>
<body>
  <h1>Каталог</h1>
  <img src="/img/hero.jpg" alt="Витрина">
  <script src="/js/app.js"></script>                    <!-- 4 -->
</body>
</html>

Построчный разбор

  • (1) main.css — блокирует рендеринг. Браузер продолжит парсить HTML, но не покажет ни пикселя, пока не построит CSSOM.
  • (2) print.css с media="print"не блокирует рендеринг. Браузер понимает, что для экрана этот файл не нужен, и скачивает его с низким приоритетом. То же с media="(min-width: 1200px)" на узком экране.
  • (3) analytics.js — самое дорогое место в файле. Синхронный скрипт останавливает парсер: дальше </head> браузер не пойдёт, пока файл не скачается и не выполнится. Хуже того — скрипт стоит ниже <link>, значит он ещё и подождёт, пока построится CSSOM.
  • (4) app.js перед </body> — классический приём «положить скрипты вниз». Он всё ещё блокирует парсер, но парсить уже почти нечего. В 2010-х это было решением; сегодня есть defer, и он лучше.

Почему CSS блокирует рендер

Каскад — это про приоритеты. Последнее правило в последнем файле может переопределить всё, что было раньше. Пока браузер не прочитал все стили, он не знает, какого цвета будет заголовок. Показать его сейчас и перекрасить через 200 мс — это мигание нестилизованного контента (FOUC, Flash of Unstyled Content), которое ощущается как поломка. Поэтому браузер честно ждёт.

Из этого следует практический вывод: размер и количество блокирующих CSS-файлов напрямую входят в LCP. Один main.css на 400 КБ, из которых для первого экрана нужно 8 КБ, — это чистая потеря времени.

Почему синхронный script блокирует парсинг

Скрипт имеет право сделать document.write('<div>') — то есть дописать HTML прямо в поток парсинга. Браузер не может знать заранее, сделает он это или нет, поэтому останавливает парсер и ждёт. Плюс скрипт может спросить getComputedStyle(el).width, а для ответа нужен CSSOM. Отсюда правило, которое стоит запомнить дословно:

Скрипт ждёт весь CSS, объявленный выше него. Парсер ждёт скрипт. Значит, <link> + синхронный <script> в <head> — это последовательная, а не параллельная задержка.

defer, async и type="module"

<!-- Блокирует парсинг. Использовать только осознанно. -->
<script src="/js/critical-inline-config.js"></script>

<!-- Качается параллельно, выполняется СРАЗУ после скачивания (прерывая парсинг).
     Порядок между несколькими async не гарантирован. -->
<script async src="/js/analytics.js"></script>

<!-- Качается параллельно, выполняется ПОСЛЕ парсинга, строго по порядку в разметке,
     перед событием DOMContentLoaded. -->
<script defer src="/js/vendor.js"></script>
<script defer src="/js/app.js"></script>

<!-- Модуль: defer включён по умолчанию, писать defer не нужно. -->
<script type="module" src="/js/main.js"></script>

<!-- Модуль, который не должен ждать парсинга, — можно пометить async. -->
<script type="module" async src="/js/widget.js"></script>
ФормаБлокирует парсер?Когда выполняетсяПорядокДля чего
<script src>Да, полностьюСразу при встречеПо разметкеПочти никогда
asyncНет на скачивании, да на выполненииКак только скачалсяНе гарантированНезависимые счётчики, аналитика
deferНетПосле парсинга, до DOMContentLoadedПо разметкеКод приложения, зависимости
type="module"НетКак deferПо разметке (+ граф импортов)Современная сборка

Две ловушки. Первая: у инлайнового скрипта (<script>код прямо тут</script>) атрибуты async и defer игнорируются — он всегда блокирует. Исключение — <script type="module">: инлайновый модуль тоже откладывается. Вторая: async на цепочке зависимых скриптов почти гарантированно даст ошибку «Cannot read properties of undefined» — если app.js зависит от vendor.js, нужен defer.

Как это работает

Когда парсер встаёт на синхронном скрипте, браузер не сидит без дела. Работает preload scanner — второй, упрощённый парсер, который забегает вперёд по сырому HTML и ищет то, что можно начать качать: <img src>, <link href>, <script src>. Именно поэтому «медленная» страница из примера всё-таки не катастрофична: пока ждём аналитику, картинка уже качается.

Но у preload scanner есть слепая зона: он видит только разметку. Ресурсы, которые появляются изнутри кода, он не находит:

  • background-image: url(...) в CSS — обнаружится только после парсинга CSS;
  • картинка, вставляемая через new Image() в JS;
  • шрифт в @font-face — до применения правила браузер даже не начнёт его качать;
  • @import внутри CSS — последовательная загрузка, худший из возможных вариантов.

Для таких случаев существует явная подсказка — <link rel="preload">: она возвращает ресурс в поле зрения сканера.

<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/img/hero.jpg" as="image" fetchpriority="high">

Частые ошибки

  • @import в CSS. Браузер должен скачать первый файл, распарсить, увидеть @import, скачать второй. Два последовательных RTT вместо одного. Заменяйте на два <link> или на сборку.
  • Синхронный скрипт «на всякий случай». Чаще всего это тег менеджера тегов или A/B-тестов. Он стоит выше всего и стоит дороже всего.
  • async там, где нужен defer. Работает через раз, падает на медленной сети — самый неприятный класс багов.
  • Один гигантский CSS-бандл. Блокирующий вес, из которого 90 % нужно на других экранах.
  • Вера в то, что «скрипт внизу body ничего не блокирует». Он не блокирует парсинг разметки, но всё ещё задерживает DOMContentLoaded и конкурирует за сеть с картинкой LCP.
  • Preload всего подряд. preload повышает приоритет — если приоритетным становится всё, приоритетов больше нет, и вы просто отбираете полосу у настоящего LCP-ресурса.

Итоги

  • Первый кадр требует DOM + CSSOM → render tree → layout → paint. Ускорять нужно то, что стоит на этом пути, остальное — вторично.
  • CSS блокирует рендеринг (иначе будет FOUC). Синхронный JS блокирует парсинг — и вдобавок ждёт CSS, объявленный выше.
  • defer — разумный вариант по умолчанию для кода приложения. async — только для полностью независимых скриптов. type="module" уже ведёт себя как defer.
  • media="print" и медиазапросы в <link> снимают блокировку рендеринга бесплатно.
  • Preload scanner спасает от многого, но не видит ресурсы, спрятанные в CSS и JS. Их вытаскивают через rel="preload" — точечно, не пачками.
Проверьте себя
1. Почему обычный <script src="app.js"></script> в <head> задерживает появление контента сильнее, чем кажется?
AОн останавливает парсер HTML, а если выше в head есть <link rel="stylesheet">, то ещё и ждёт загрузки этого CSS, потому что скрипт может прочитать вычисленные стили
BОн заставляет браузер заново скачивать HTML-документ целиком
CОн отключает preload scanner для всей страницы до конца загрузки
DОн выполняется дважды: один раз при парсинге, второй раз на DOMContentLoaded
2. Чем defer отличается от async для внешнего скрипта?
Adefer скачивает файл позже, чем async, — уже после DOMContentLoaded
BОба скачиваются параллельно парсингу, но defer выполняется после полного парсинга и сохраняет порядок скриптов, а async выполняется сразу после скачивания и порядок не гарантирует
Casync сохраняет порядок скриптов, а defer выполняет их в случайном порядке
Ddefer работает только для модулей, а async — только для классических скриптов