Core Web Vitals: метрики, оптимизация и бюджеты производительности
В краткой версии ты прогнал Lighthouse, увидел три цифры и оптимизировал самое очевидное. Эта глава — про то, как эти цифры устроены изнутри, чтобы ты оптимизировал причину, а не симптом. Потому что «уменьшил бандл на 200 КБ, а LCP не сдвинулся» — типовая ситуация: LCP зависит не от бандла, а от приоритета загрузки главного изображения. INP — не от количества JS вообще, а от длительных задач в момент взаимодействия. Метрики точны, но каждая смотрит в свою щель.
Продакшен-контекст: Google использует Core Web Vitals как фактор ранжирования (и отдельный отчёт в Search Console), но главное — это единственные метрики, за которыми стоят реальные пользователи, а не синтетический прогон. Дисциплина «замер → гипотеза → изменение → замер» с фиксированным бюджетом в CI — это то, что отличает проект, который не деградирует, от проекта, который медленно «толстеет» до неюзабельности.
LCP: Largest Contentful Paint
Заголовок раздела «LCP: Largest Contentful Paint»Что именно измеряется
Заголовок раздела «Что именно измеряется»LCP — время от начала загрузки страницы до отрисовки самого большого видимого элемента во вьюпорте. Кандидаты: <img>, background-image в CSS, заголовок с текстом, блок с текстом. Пороги: ≤ 2.5 с — хорошо, ≤ 4 с — «нужно улучшение», больше — плохо (полная классификация — на web.dev).
Важные нюансы механики:
- Только то, что видно во вьюпорте. Гигантское изображение ниже первого экрана в LCP не попадает.
- Измеряется момент отрисовки, не загрузки. Картинка может скачаться за 300 мс, но если браузер отрисует её после блокирующего CSS и двух ререндеров SPA — LCP будет на месте отрисовки.
- Самый большой элемент может смениться по ходу загрузки: сначала заголовок, потом hero-изображение победило — метрика фиксирует последнее изменение.
Оптимизация: четыре рычага
Заголовок раздела «Оптимизация: четыре рычага»1. TTFB — серверное время. LCP не может быть быстрее TTFB. Если сервер отвечает 1.8 с, остальные оптимизации борются за оставшиеся 0.7 с. Рычаги: кэширование (CDN, edge-кэш для SSG-страниц), сжатие (brotli), HTTP/2-мультиплексирование, серверный рендеринг первого экрана вместо пустого index.html.
2. Приоритет LCP-ресурса. Браузер не знает, какое изображение главное, пока не распарсит CSS и DOM. Подскажи ему:
<!-- картинка стартует с высшим приоритетом, конкуренция с другими ресурсами снимается --><link rel="preload" as="image" href="/hero.avif" fetchpriority="high" />// в Next.js: priority = preload + fetchpriority="high" + отключение lazy<Image src="/hero.jpg" alt="Платформа в работе" width={1600} height={900} priority />3. Устранение блокирующих ресурсов. CSS — render-blocking по определению: браузер не рисует, пока не получит все стили. Минифицируй, критический CSS инлайни, остальное грузи асинхронно. Сторонние скрипты (A/B-тесты, аналитика) — самые частые виновники: откладывай их до requestIdleCallback или грузи после взаимодействия.
4. Клиентский рендеринг — главный риск LCP. CSR-приложение показывает белый экран до загрузки всего JS + исполнения + запроса данных. SSR/SSG убирают три из четырёх этапов. Если CSR неизбежен — хотя бы shell с skeleton и streaming-рендеринг.
INP: Interaction to Next Paint
Заголовок раздела «INP: Interaction to Next Paint»Механика: почему «быстрый клик» — это ложь
Заголовок раздела «Механика: почему «быстрый клик» — это ложь»INP заменил FID в 2024 году и измеряет другое: не задержку первого ввода, а весь жизненный цикл взаимодействия — от клика до отрисовки ответа на экране. По всем взаимодействиям за сессию берётся худшее (фактически ~98-й перцентиль, игнорируя выбросы). Порог: ≤ 200 мс. Вводная статья по метрике — web.dev/articles/inp.
Ключевой инсайт: браузеру нужно время на три этапа — обработчик события (input delay), перерендер (processing), отрисовку (presentation). Тяжёлый обработчик на 150 мс + ререндер на 100 мс + отрисовка 50 мс = INP 300 мс, хотя «код обработчика быстрый». Реакт-разработчики часто забывают про presentation: гигантский список, который ререндерится целиком на каждый ввод, убивает INP даже при мгновенном setState.
Long tasks и разбиение
Заголовок раздела «Long tasks и разбиение»Главный поток — один. Любая задача длиннее 50 мс — «длинная» (long task): следующий клик встанет в очередь и подождёт её конца. Источники: парсинг большого JS-чанка при гидратации, тяжёлые вычисления (сортировка, фильтрация больших массивов), синхронные JSON.stringify гигантского состояния.
Стратегии разбиения:
// 1. Yield to main thread: уступи кусок главного потока между итерациямиasync function processChunked(items: Item[], onItem: (i: Item) => void) { for (let i = 0; i < items.length; i++) { onItem(items[i]); if (i % 100 === 0) { // каждые 100 элементов — пауза, браузер обработит ввод и отрисовку await new Promise((r) => setTimeout(r, 0)); } }}
// 2. scheduler.yield() — современный API (baseline 2024+), сохраняет приоритет задачиasync function process(items: Item[]) { for (const item of items) { processItem(item); if (scheduler.yield) await scheduler.yield(); }}
// 3. Web Worker — вычисления вне главного потока совсемconst worker = new Worker(new URL('./filter.worker.ts', import.meta.url));worker.postMessage({ items, query });worker.onmessage = (e) => setFiltered(e.data.result); // ответ приходит, поток свободенВоркер — максимальный выигрыш для чистых вычислений (нет DOM, но и нет блокировки), цена — сериализация данных при postMessage и усложнение архитектуры. Для фильтрации списка на 10 000 элементов — разумно; для сортировки 50 элементов — оверкилл. scheduler.yield() — относительно свежий API, справка — MDN.
Transitions и не-срочные обновления
Заголовок раздела «Transitions и не-срочные обновления»// поиск: инпут отзывчив немедленно, список подстроится без блокировкиconst [query, setQuery] = useState('');const [deferredQuery, setDeferredQuery] = useState('');const [isPending, startTransition] = useTransition();
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => { const value = e.target.value; setQuery(value); // срочно: текст в инпуте появляется сразу startTransition(() => setDeferredQuery(value)); // не срочно: фильтрация и ререндер списка};
// useDeferredValue — тот же приём для значений извнеconst deferredList = useDeferredValue(filteredList);startTransition говорит React: этот апдейт может прерываться вводом пользователя. React разбивает работу на куски и уступает поток при новых событиях. Это не делает вычисление быстрее — оно делает интерфейс отзывчивым во время вычисления.
CLS: Cumulative Layout Shift
Заголовок раздела «CLS: Cumulative Layout Shift»Механика
Заголовок раздела «Механика»CLS — сумма всех неожиданных сдвигов макета за жизнь страницы. Сдвиг = видимый элемент меняет позицию между двумя кадрами. Формула: доля вьюпорта, на которую сдвинулся элемент × расстояние сдвига. Порог: ≤ 0.1. Подробный разбор метрики и её источников — web.dev/articles/cls.
Три виновника и лечение
Заголовок раздела «Три виновника и лечение»1. Медиа без размеров. Изображение, приехавшее без width/height, раздвигает текст вниз — классический сдвиг. Лечение: всегда явные размеры; если неизвестны — резерв через aspect-ratio:
.card-image { aspect-ratio: 16 / 9; width: 100%; object-fit: cover; }2. Шрифты. Пока веб-шрифт грузится, текст рендерится fallback’ом; когда шрифт приехал — текст перекомпоновывается (FOUT-сдвиг). Лечение:
@font-face { font-family: 'Inter'; src: url('/fonts/Inter-var.woff2') format('woff2'); font-display: swap; /* fallback сразу, замена без блокировки */ size-adjust: 100%; /* подгон метрик, снижает сдвиг при замене */}<link rel="preload" href="/fonts/Inter-var.woff2" as="font" type="font/woff2" crossorigin />Современный эталон: variable-шрифт одним файлом + preload + font-display: swap. Дополнительно — font-metric-overrides (@font-face descriptors) для точной подгонки fallback’а.
3. Динамический контент, вставляемый над текущим. Баннер кукисов, рекламный слот, результат поиска, вставляемый в начало списка. Лечение: резервируй место (min-height под баннер, skeleton под слот), вставляй после текущей позиции, никогда не вставляй интерактивный контент, пока пользователь взаимодействует с этой зоной.
// плохо: баннер вставился вверху — весь контент съехал, пользователь промахнулся по кнопке// хорошо: место зарезервировано с самого начала{showBanner && <div className="cookie-banner" /* position: fixed внизу или зарезервированный слот */ />}Исключение из метрики: сдвиги в течение 500 мс после явного пользовательского ввода не считаются (например, раскрытие аккордеона по клику — это не CLS).
Лабораторные против полевых: чему верить
Заголовок раздела «Лабораторные против полевых: чему верить»Две категории данных, которые путают чаще всего:
| Лабораторные | Полевые | |
|---|---|---|
| Что | Синтетический прогон: одно устройство, заданная сеть, тёплый кэш | Реальные пользователи: разные устройства, сети, кэши, моменты взаимодействия |
| Инструменты | Lighthouse, WebPageTest, CI-прогоны | CrUX (Chrome UX Report, данные Chrome), PageSpeed Insights, RUM — web-vitals в твоей аналитике |
| Сильная сторона | Воспроизводимость, дебаг: почему медленно | Правда: как оно есть у реальных людей; INP вообще нормально меряется только полево |
| Слабая сторона | Не видит реальных взаимодействий, кэшей, устройств | Шум, нет деталей «почему», задержка данных (28 день) |
Дисциплина: оптимизируй по полевым, дебажь по лабораторным. Search Console и PageSpeed Insights говорят «у тебя проблема на мобильных в Индии»; Lighthouse на твоём MacBook с гигабитом покажет, какой ресурс виноват.
// RUM: собирай свои полевые данные, не завися от Googleimport { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
function send(name: string, value: number, id: string) { // отправляй в свой аналитический эндпоинт: метрика + URL + connection тип + страна navigator.sendBeacon('/api/vitals', JSON.stringify({ name, value, id, page: location.pathname }));}
onLCP((m) => send('LCP', m.value, m.id));onINP((m) => send('INP', m.value, m.id));onCLS((m) => send('CLS', m.value, m.id));Перцентили: смотри на 75-й (порог Google), но для своей аналитики интересен и 95-й — он показывает страдания на слабых устройствах.
Оптимизация: бандл и загрузка
Заголовок раздела «Оптимизация: бандл и загрузка»Код-сплиттинг: React.lazy и route-based splitting
Заголовок раздела «Код-сплиттинг: React.lazy и route-based splitting»import { lazy, Suspense } from 'react';
// чанк грузится только когда компонент впервые рендеритсяconst ChartPanel = lazy(() => import('./ChartPanel'));const MarkdownEditor = lazy(() => import('./MarkdownEditor'));
function Dashboard() { const [tab, setTab] = useState<'chart' | 'text'>('chart'); return ( <Suspense fallback={<PanelSkeleton />}> {tab === 'chart' ? <ChartPanel /> : <MarkdownEditor />} </Suspense> );}Принципы: сплиттить по маршрутам (каждая страница — чанк, грузится по навигации), по тяжёлым зависимостям (графики, редакторы, pdf), по редким действиям (модалки с формами). Не сплитти всё подряд — каждый чанк — это HTTP-запрос и каскад; инлайн-критический, остальное гранулярно.
В Next.js route-based splitting автоматический: каждый файл в app/ — отдельный чанк. Остаётся ленивить тяжёлые клиентские компоненты внутри страниц.
Resource hints: prefetch, preload, preconnect
Заголовок раздела «Resource hints: prefetch, preload, preconnect»<!-- preload: ресурс НУЖЕН для текущей страницы, без него хуже. Лимит: 1-2 штуки --><link rel="preload" as="image" href="/hero.avif" fetchpriority="high" /><link rel="preload" href="/fonts/Inter-var.woff2" as="font" type="font/woff2" crossorigin />
<!-- prefetch: ресурс понадобится СЛЕДУЮЩЕЙ странице, браузер качает в простое --><link rel="prefetch" href="/checkout" /><!-- Next.js: <Link prefetch> делает это автоматически для маршрутов во вьюпорте -->
<!-- preconnect: заранее открыть соединение (DNS + TCP + TLS) к чужому origin --><link rel="preconnect" href="https://api.example.com" /><link rel="dns-prefetch" href="https://cdn.example.com" />
<!-- modulepreload: граф зависимостей модуля до момента запроса --><link rel="modulepreload" href="/chunks/graph-lib.js" />Дисциплина: preload и fetchpriority="high" на одном ресурсе — да; на пяти ресурсах — они конкурируют друг с другом, эффект нулевой (как работает Fetch Priority — на web.dev). Preconnect к origin, с которым соединение устанавливается дольше 300 мс — бесплатный выигрыш.
Оптимизация изображений — чек-лист
Заголовок раздела «Оптимизация изображений — чек-лист»- Формат: AVIF/WebP везде, JPEG/PNG — только для осознанных исключений. Сокращение в 3-5 раз без видимой потери.
- Размеры: грузи то, что нужно вьюпорту —
srcset/sizesили<Image>из Next.js (генерирует на лету). loading="lazy"для всего ниже первого экрана; LCP-картинке —priority, а не lazy.- CDN с оптимизацией (Cloudflare Images, Imgix, Vercel Image Optimization) — ресайз на edge, а не в твоём Node.js.
- Вектор для иконок: SVG-спрайт или инлайн-компоненты, вместо PNG-спрайтов.
Бюджеты производительности
Заголовок раздела «Бюджеты производительности»Бюджет — это порог, при превышении которого CI падает. Без бюджетов каждая фича привозит «ещё чуть-чуть» и через полгода страница весит вдвое больше.
// budgets.json для Lighthouse CI[ { "path": "/*", "timings": [{ "metric": "largest-contentful-paint", "budget": 2500 }] }, { "path": "/*", "resourceCounts": [{ "resourceType": "script", "budget": 40 }] }, { "path": "/*", "resourceSizes": [ { "resourceType": "script", "budget": 300 }, // КБ всех скриптов { "resourceType": "image", "budget": 800 }, { "resourceType": "total", "budget": 1500 } ]}]# прогон в CI против preview-деплояnpx @lhci/cli autorun --collect.url=$PREVIEW_URL --assert.assertionSet=budgetsПрактика бюджетов: зафиксируй текущие значения как базовую линию (не идеал — реальность), поставь порог +10-15%, дальше двигаи линию только вниз. Размер бандла смотри на каждый PR: next build → сравни чанки с main; size-limit для библиотек; bundlephobia перед добавлением новой зависимости — «сколько это нам стоит?» должен быть рутинным вопросом на ревью.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Оптимизация по лабораторному Lighthouse вместо полевых данных. Локально LCP 1.2 с, а у реальных пользователей 4 с — потому что они на Redmi в 3G. Хорошо: RUM через
web-vitals, дебаг через Lighthouse. - Всё подряд в
React.lazy. Каскад чанков: страница → чанк → внутри ещё чанк → spinner в spinner. Хорошо: route-based splitting как основа, ленивка для тяжёлых изолированных блоков, suspense-границы осмысленные. fetchpriority="high"на половине ресурсов. Приоритет — относительная величина: если его у всех, то ни у кого. Хорошо: строго 1-2 ресурса на страницу (LCP-изображение, критический скрипт).- Изображения без размеров «потом поправим». Классический CLS 0.3+ из-за hero-баннеров. Хорошо:
width/heightилиaspect-ratioобязательны, проверка в CI-скриншотах. - «Оптимизировали» INP спиннером. INP меряет до значимой отрисовки; спиннер сам является отрисовкой, но если после него всё равно 800 мс до результата — метрика не улучшилась. Хорошо: разбиение long tasks, transitions, воркеры.
- Сторонние скрипты без контроля. Чат поддержки, аналитика, A/B-платформа — каждый блокирует рендер по умолчанию. Хорошо: async/defer, загрузка по взаимодействию,
preconnect, регулярный аудит «кто сколько добавляет к TTFB».
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Чем INP отличается от FID и почему заменил его? FID мерял только задержку первого ввода (input delay); INP — полный цикл «взаимодействие → отрисовка ответа» по всем событиям сессии, включая обработку и presentation. FID мог быть 0 мс при тяжёлом рендере — INP это ловит.
- LCP 3.8 с. Как искать причину? Девать по шагам: TTFB (сервер/CDN) → discoverability LCP-ресурса (preload, fetchpriority) → render-blocking CSS/JS → клиентский рендеринг. DevTools Performance показывает каждый этап на таймлайне.
- Что такое long task и как с ним бороться? Задача > 50 мс в главном потоке. Разбиение через
scheduler.yield/setTimeout-куски, перенос в Web Worker, уменьшение объёма работы (виртуализация списков, мемоизация). - Как работает
startTransitionи чем отличается от дебаунса? Помечает апдейт как не-срочный: React прерывает его при новом вводе. Дебаунс откладывает работу на N мс; transition — выполняет сразу, но с возможностью прерывания. Transition лучше для INP, дебаунс — для сетевых запросов. - Что считается CLS, а что нет? Сдвиг макета вне 500 мс после явного пользовательского ввода — не считается (клик по аккордеону). Сдвиги от асинхронной загрузки (изображения, шрифты, вставленный контент) — считаются.
- Когда
preload, когдаprefetch, когдаpreconnect? Preload — ресурс критичен для текущей страницы. Prefetch — понадобится следующей странице, качается в простое. Preconnect — раннее открытие соединения к чужому origin (DNS+TCP+TLS), без загрузки ресурса. - Что такое performance-бюджет и как его внедрить? Количественный порог (размер JS, LCP, количество запросов), при превышении падает CI. Внедрение: замер линии, порог +10-15%, Lighthouse CI или кастомные проверки на PR, движение линии вниз по мере оптимизаций.
- Почему SSR улучшает LCP, но может ухудшить INP? SSR ускоряет первую отрисовку (LCP-элемент в HTML сразу). Но весь тот же JS всё равно грузится и гидратируется — если гидратация тяжёлая, первые взаимодействия попадают на занятый поток и INP страдает. Отсюда partial hydration/islands.
Практика
Заголовок раздела «Практика»- Базовая линия. Прогони главную страницу pet-проекта в Lighthouse (mobile, 3 прогона) и зафиксируй LCP/INP/CLS + размеры скриптов. Критерий: отчёт в репозитории, цифры в README — это твоя линия.
- LCP-оптимизация. Найди LCP-элемент через DevTools Performance; добавь
priority/preload, убери один render-blocking скрипт. Критерий: LCP-линия сдвинулась вниз ≥ 20% в трёх повторных прогонах. - Разбиение long task. Найди самую тяжёлую операцию (фильтрация/сортировка списка), перенеси в воркер или разбей с
scheduler.yield. Критерий: Performance-запись показывает отсутствие задач > 50 мс при взаимодействии. - Устранение CLS. Пройди DevTools → Rendering → Layout Shift Regions, устрани каждый сдвиг: размеры медиа, резерв под баннер, шрифты. Критерий: CLS = 0 в течение полной сессии с проскроллом.
- Бюджет в CI. Настрой Lighthouse CI с budgets.json на preview-деплои: script ≤ текущего +10%, LCP ≤ 2.5 с. Критерий: тестовый PR с лишней зависимостью (например, moment) падает в CI; после её удаления — зелёный.