Что происходит от запроса до первого пикселя
Между «браузер получил первый байт HTML» и «пользователь увидел заголовок» лежит вполне конкретная цепочка шагов. Разберём её по кадрам — и станет понятно, что именно её тормозит.
Критический путь рендеринга (Critical Rendering Path) — минимальная последовательность действий браузера, которую нужно полностью завершить, чтобы показать первый кадр страницы: разобрать HTML, разобрать CSS, объединить их, посчитать геометрию и залить пиксели.
Зачем это знать
Метрики Core Web Vitals — LCP, INP, CLS — это не самостоятельные сущности, а следствия. LCP плохой не «сам по себе»: он плохой, потому что где-то в критическом пути стоит ресурс, которого браузер вынужден ждать. Пока вы не видите путь целиком, оптимизация превращается в гадание: сжали картинки — не помогло, включили HTTP/2 — не помогло, а виноват был один синхронный скрипт аналитики в <head>.
Хорошая новость: путь короткий и полностью детерминированный. Пять шагов, и в каждом есть ровно одна причина, по которой браузер может встать.
Пять шагов от байтов до пикселей
- DOM. Байты HTML → токены → узлы → дерево документа. Строится инкрементально: браузер не ждёт последнего байта, он парсит по мере поступления.
- CSSOM. Все стили → объектная модель со всеми вычисленными правилами. В отличие от DOM, строится «всё или ничего».
- Дерево рендеринга (render tree). DOM + CSSOM = только то, что реально видно. Узлы с
display: noneсюда не попадают (а вотvisibility: hidden— попадает, он занимает место). - Layout (reflow). Браузер считает точную геометрию: где и какого размера каждый прямоугольник. До этого шага размеры в CSS — просто числа, не координаты.
- 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"— точечно, не пачками.