Коротка відповідь. 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, у порядку частоти:

  1. Hero-зображення без пріоритету. Браузер дізнається про нього пізно (після JS), або воно не оптимізоване. У Next.js: <Image priority /> для LCP-елемента, сучасні формати, коректний sizes.
  2. Шрифти. Текст-LCP чекає на webfont → невидимий текст. next/font із display: swap знімає більшу частину болю.
  3. Ланцюжок client-side запитів. Сторінка рендериться, потім useEffect → fetch → ще fetch, і лише тоді з'являється контент. Це архітектурна проблема: дані для першого екрана мають їхати з сервером (SSR/RSC), а не докуповуватись на клієнті.
  4. Повільний сервер (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? Одна знайдена причина з реальних даних вартує більше за будь-який чекліст.