Коротка відповідь. Core Web Vitals – це три польові метрики Google: LCP ≤ 2.5 с (швидкість появи головного контенту), INP ≤ 200 мс (відгук на взаємодії), CLS ≤ 0.1 (стабільність верстки). Рахуються вони на 75-му перцентилі реальних користувачів, окремо для mobile і desktop – тому зелений Lighthouse на твоєму MacBook нічого не гарантує. Для React-розробника кожна метрика має свої типові причини: LCP псують зображення і шрифти, INP – довгі таски й важкі обробники, CLS – контент, що доїжджає пізніше за верстку. Нижче – розбір кожної і чесний спосіб вимірювати.
Lab vs field: чому цифри не сходяться
Лабораторні дані (Lighthouse, локальний DevTools) – це синтетичний прогін у контрольованих умовах: одне завантаження, емульований телефон, без людини. Польові дані (CrUX, власний збір) – те, що пережили реальні користувачі за 28 днів.
Розходяться вони постійно, і найяскравіший приклад – INP: web.dev прямо пише, що інструменти на кшталт Lighthouse фізично не можуть виміряти INP, бо в синтетичному прогоні немає взаємодій – замість нього показують proxy-метрику TBT. Тому порядок роботи завжди такий: поле каже, ЩО болить, лабораторія допомагає зрозуміти, ЧОМУ.
Оцінка проходження проста: усі три метрики в «зеленій» зоні на 75-му перцентилі. Тобто якщо у 26% користувачів LCP гірший за 2.5 с – метрика червона, навіть якщо медіана прекрасна.
LCP: головний контент за 2.5 секунди
LCP фіксує момент, коли відрендерився найбільший елемент у viewport – зазвичай hero-зображення або заголовок. Типові React/Next-причини повільного LCP, у порядку частоти:
- Hero-зображення без пріоритету. Браузер дізнається про нього пізно (після JS), або воно не оптимізоване. У Next.js:
<Image priority />для LCP-елемента, сучасні формати, коректнийsizes. - Шрифти. Текст-LCP чекає на webfont → невидимий текст.
next/fontізdisplay: swapзнімає більшу частину болю. - Ланцюжок client-side запитів. Сторінка рендериться, потім
useEffect→ fetch → ще fetch, і лише тоді з'являється контент. Це архітектурна проблема: дані для першого екрана мають їхати з сервером (SSR/RSC), а не докуповуватись на клієнті. - Повільний сервер (TTFB). Жодна фронтенд-оптимізація не врятує, якщо HTML їде 2 секунди – дивись кешування і регіони (для Next це тема ментальної моделі навігацій).
INP: відгук за 200 мс
INP вимірює затримку між взаємодією (клік, тап, клавіша) і наступним кадром після неї – по всій сесії, береться найгірша. Замінила FID у 2024-му і куди жорсткіша, бо ловить не лише перший клік.
React-специфічні вбивці INP:
- Довгі таски на main thread. Великий ре-рендер на кожен клік = сотні мс без кадрів. Ліки: правильна структура стану і мемоізація там, де вона виправдана – я детально розбирав це в статті про оптимізацію React, а React Compiler частину цієї роботи тепер забирає на себе.
- Синхронна робота в обробниках. Фільтрація тисячі елементів прямо в
onChange– класика.useTransition/useDeferredValueіснують саме для цього: терміновий апдейт (введений символ) окремо, важкий (перерахунок списку) – у фоні. - Hydration великих сторінок. Поки React гідрує мегабайти компонентів, кліки чекають. Server Components і менші клієнтські острови – системна відповідь.
CLS: верстка не стрибає
CLS сумує несподівані зсуви верстки. «Несподівані» – ключове слово: зсув після кліку користувача не рахується, зсув від картинки, що доїхала, – рахується.
Джерела в React-застосунках: зображення без розмірів (next/image вирішує, бо резервує місце), банери/alert-и, що вставляються зверху після завантаження даних, шрифт із іншими метриками (fallback-текст стрибає), скелетони, які не збігаються за розміром із реальним контентом. Правило одне: усе, що з'явиться пізніше, має мати зарезервоване місце вже зараз.
Як міряти по-чесному: web-vitals у production
Бібліотека web-vitals – офіційний спосіб зібрати ті самі значення, які бачить Google. Мінімальна інтеграція в наш стек (PostHog уже є):
// app/web-vitals.ts – викликати з клієнтського компонента в layout
import { onLCP, onINP, onCLS, type Metric } from 'web-vitals';
import posthog from 'posthog-js';
function report(metric: Metric) {
posthog.capture('web_vitals', {
metric_name: metric.name, // 'LCP' | 'INP' | 'CLS'
metric_value: metric.value, // мс або безрозмірний CLS
metric_rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
metric_id: metric.id, // дедуплікація в аналітиці
});
}
export function initWebVitals() {
onLCP(report);
onINP(report);
onCLS(report);
}Далі в аналітиці дивишся 75-й перцентиль по metric_value з розбивкою mobile/desktop – і отримуєш власні польові дані швидше, ніж CrUX збере свої 28 днів. Безкоштовна альтернатива для старту – PageSpeed Insights, який показує CrUX-дані твого домену, якщо трафіку достатньо.
Пріоритизація: лагодь за впливом, не за списком
Антипатерн – «зробимо всі пункти з аудиту». Нормальний процес: подивитись, яка метрика червона в полі → знайти сторінки з найбільшим трафіком серед проблемних → полагодити одну причину → переміряти. Оптимізація LCP на сторінці, куди заходить 50 людей на місяць, – це хобі, а не перформанс-робота.
І окремо про чесність: я свідомо не наводжу тут «до/після: було 4.1 с, стало 1.9 с» із вигаданого проєкту. Числа мають сенс лише твої власні – методика вище дає їх за тиждень збору.
Типові помилки
- Оптимізувати під Lighthouse-бал, а не під польові метрики. Бал – проксі, ранжування і користувачі живуть у полі.
- Міряти лише на десктопі. Поріг один, а 75-й перцентиль mobile зазвичай удвічі гірший.
- Лікувати INP через
useMemoна все. Спершу профіль: може, проблема в одному обробнику, а не в рендерингу взагалі. - Скелетони заради скелетонів, які самі створюють CLS, бо не збігаються з контентом за висотою.
- Разове вимірювання. Vitals – це моніторинг, не аудит раз на квартал: кожен реліз може зрушити метрики.
Практичне завдання
Підключи web-vitals до свого проєкту (20 хвилин разом з аналітикою), збери тиждень даних і побудуй три числа: 75-й перцентиль LCP, INP, CLS на mobile. Потім відкрий найповільнішу за LCP сторінку в DevTools → Performance, запиши trace завантаження і знайди LCP-елемент у таймлайні: що блокувало його появу – сервер, зображення, шрифт чи JS? Одна знайдена причина з реальних даних вартує більше за будь-який чекліст.