Перейти к содержимому

Core Web Vitals: метрики, оптимизация и бюджеты производительности

В краткой версии ты прогнал Lighthouse, увидел три цифры и оптимизировал самое очевидное. Эта глава — про то, как эти цифры устроены изнутри, чтобы ты оптимизировал причину, а не симптом. Потому что «уменьшил бандл на 200 КБ, а LCP не сдвинулся» — типовая ситуация: LCP зависит не от бандла, а от приоритета загрузки главного изображения. INP — не от количества JS вообще, а от длительных задач в момент взаимодействия. Метрики точны, но каждая смотрит в свою щель.

Продакшен-контекст: Google использует Core Web Vitals как фактор ранжирования (и отдельный отчёт в Search Console), но главное — это единственные метрики, за которыми стоят реальные пользователи, а не синтетический прогон. Дисциплина «замер → гипотеза → изменение → замер» с фиксированным бюджетом в CI — это то, что отличает проект, который не деградирует, от проекта, который медленно «толстеет» до неюзабельности.

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 заменил FID в 2024 году и измеряет другое: не задержку первого ввода, а весь жизненный цикл взаимодействия — от клика до отрисовки ответа на экране. По всем взаимодействиям за сессию берётся худшее (фактически ~98-й перцентиль, игнорируя выбросы). Порог: ≤ 200 мс. Вводная статья по метрике — web.dev/articles/inp.

Ключевой инсайт: браузеру нужно время на три этапа — обработчик события (input delay), перерендер (processing), отрисовку (presentation). Тяжёлый обработчик на 150 мс + ререндер на 100 мс + отрисовка 50 мс = INP 300 мс, хотя «код обработчика быстрый». Реакт-разработчики часто забывают про presentation: гигантский список, который ререндерится целиком на каждый ввод, убивает INP даже при мгновенном setState.

Главный поток — один. Любая задача длиннее 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.

// поиск: инпут отзывчив немедленно, список подстроится без блокировки
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 — сумма всех неожиданных сдвигов макета за жизнь страницы. Сдвиг = видимый элемент меняет позицию между двумя кадрами. Формула: доля вьюпорта, на которую сдвинулся элемент × расстояние сдвига. Порог: ≤ 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: собирай свои полевые данные, не завися от Google
import { 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-й — он показывает страдания на слабых устройствах.

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/ — отдельный чанк. Остаётся ленивить тяжёлые клиентские компоненты внутри страниц.

<!-- 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 мс — бесплатный выигрыш.

  1. Формат: AVIF/WebP везде, JPEG/PNG — только для осознанных исключений. Сокращение в 3-5 раз без видимой потери.
  2. Размеры: грузи то, что нужно вьюпорту — srcset/sizes или <Image> из Next.js (генерирует на лету).
  3. loading="lazy" для всего ниже первого экрана; LCP-картинке — priority, а не lazy.
  4. CDN с оптимизацией (Cloudflare Images, Imgix, Vercel Image Optimization) — ресайз на edge, а не в твоём Node.js.
  5. Вектор для иконок: 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 перед добавлением новой зависимости — «сколько это нам стоит?» должен быть рутинным вопросом на ревью.

  1. Оптимизация по лабораторному Lighthouse вместо полевых данных. Локально LCP 1.2 с, а у реальных пользователей 4 с — потому что они на Redmi в 3G. Хорошо: RUM через web-vitals, дебаг через Lighthouse.
  2. Всё подряд в React.lazy. Каскад чанков: страница → чанк → внутри ещё чанк → spinner в spinner. Хорошо: route-based splitting как основа, ленивка для тяжёлых изолированных блоков, suspense-границы осмысленные.
  3. fetchpriority="high" на половине ресурсов. Приоритет — относительная величина: если его у всех, то ни у кого. Хорошо: строго 1-2 ресурса на страницу (LCP-изображение, критический скрипт).
  4. Изображения без размеров «потом поправим». Классический CLS 0.3+ из-за hero-баннеров. Хорошо: width/height или aspect-ratio обязательны, проверка в CI-скриншотах.
  5. «Оптимизировали» INP спиннером. INP меряет до значимой отрисовки; спиннер сам является отрисовкой, но если после него всё равно 800 мс до результата — метрика не улучшилась. Хорошо: разбиение long tasks, transitions, воркеры.
  6. Сторонние скрипты без контроля. Чат поддержки, аналитика, A/B-платформа — каждый блокирует рендер по умолчанию. Хорошо: async/defer, загрузка по взаимодействию, preconnect, регулярный аудит «кто сколько добавляет к TTFB».
  1. Чем INP отличается от FID и почему заменил его? FID мерял только задержку первого ввода (input delay); INP — полный цикл «взаимодействие → отрисовка ответа» по всем событиям сессии, включая обработку и presentation. FID мог быть 0 мс при тяжёлом рендере — INP это ловит.
  2. LCP 3.8 с. Как искать причину? Девать по шагам: TTFB (сервер/CDN) → discoverability LCP-ресурса (preload, fetchpriority) → render-blocking CSS/JS → клиентский рендеринг. DevTools Performance показывает каждый этап на таймлайне.
  3. Что такое long task и как с ним бороться? Задача > 50 мс в главном потоке. Разбиение через scheduler.yield/setTimeout-куски, перенос в Web Worker, уменьшение объёма работы (виртуализация списков, мемоизация).
  4. Как работает startTransition и чем отличается от дебаунса? Помечает апдейт как не-срочный: React прерывает его при новом вводе. Дебаунс откладывает работу на N мс; transition — выполняет сразу, но с возможностью прерывания. Transition лучше для INP, дебаунс — для сетевых запросов.
  5. Что считается CLS, а что нет? Сдвиг макета вне 500 мс после явного пользовательского ввода — не считается (клик по аккордеону). Сдвиги от асинхронной загрузки (изображения, шрифты, вставленный контент) — считаются.
  6. Когда preload, когда prefetch, когда preconnect? Preload — ресурс критичен для текущей страницы. Prefetch — понадобится следующей странице, качается в простое. Preconnect — раннее открытие соединения к чужому origin (DNS+TCP+TLS), без загрузки ресурса.
  7. Что такое performance-бюджет и как его внедрить? Количественный порог (размер JS, LCP, количество запросов), при превышении падает CI. Внедрение: замер линии, порог +10-15%, Lighthouse CI или кастомные проверки на PR, движение линии вниз по мере оптимизаций.
  8. Почему SSR улучшает LCP, но может ухудшить INP? SSR ускоряет первую отрисовку (LCP-элемент в HTML сразу). Но весь тот же JS всё равно грузится и гидратируется — если гидратация тяжёлая, первые взаимодействия попадают на занятый поток и INP страдает. Отсюда partial hydration/islands.
  1. Базовая линия. Прогони главную страницу pet-проекта в Lighthouse (mobile, 3 прогона) и зафиксируй LCP/INP/CLS + размеры скриптов. Критерий: отчёт в репозитории, цифры в README — это твоя линия.
  2. LCP-оптимизация. Найди LCP-элемент через DevTools Performance; добавь priority/preload, убери один render-blocking скрипт. Критерий: LCP-линия сдвинулась вниз ≥ 20% в трёх повторных прогонах.
  3. Разбиение long task. Найди самую тяжёлую операцию (фильтрация/сортировка списка), перенеси в воркер или разбей с scheduler.yield. Критерий: Performance-запись показывает отсутствие задач > 50 мс при взаимодействии.
  4. Устранение CLS. Пройди DevTools → Rendering → Layout Shift Regions, устрани каждый сдвиг: размеры медиа, резерв под баннер, шрифты. Критерий: CLS = 0 в течение полной сессии с проскроллом.
  5. Бюджет в CI. Настрой Lighthouse CI с budgets.json на preview-деплои: script ≤ текущего +10%, LCP ≤ 2.5 с. Критерий: тестовый PR с лишней зависимостью (например, moment) падает в CI; после её удаления — зелёный.