Core Web Vitals: LCP, INP, CLS

Три метрики, за которыми стоят три разные жалобы живого человека: «долго ничего не видно», «нажал — не реагирует», «хотел кликнуть — всё уехало».

Core Web Vitals — набор из трёх измеримых показателей (LCP, INP, CLS), которыми Google описывает качество опыта на странице: как быстро появился главный контент, насколько быстро страница отвечает на действия и не прыгает ли вёрстка под руками.

Зачем это разработчику

Разговор о производительности почти всегда начинается со слов «у нас вроде быстро». Это гадание. «Вроде быстро» — это ваш ноутбук, кабельный интернет и горячий кеш, в котором уже лежит вся статика сайта. А у пользователя — недорогой Android, 4G в метро и первый визит с пустым кешем. Core Web Vitals нужны ровно затем, чтобы вместо «вроде» появилось число: его можно сравнить с порогом, положить в дашборд и проверить после релиза.

Практический смысл двойной. Во-первых, это фактор ранжирования Google: не главный, но при прочих равных он решает, кто выше. Во-вторых — и это важнее — каждая из трёх метрик прицеплена к деньгам. Медленный показ убивает конверсию на входе: человек не дождался и ушёл. Тормозящие клики бесят в середине воронки. Скачущая вёрстка приводит к случайным нажатиям «не туда» и возвратам.

Ключевая мысль всего учебника простая: измеряй, а не гадай. Оптимизация не считается сделанной, пока вы не показали число «до» и число «после».

LCP — Largest Contentful Paint

LCP (наибольшая контентная отрисовка) — момент, когда в видимой части экрана появился самый крупный контентный элемент: обычно это главная картинка, постер видео или крупный блок текста. Это ответ на вопрос «когда пользователь увидел, что страница о чём-то полезном», а не «когда браузер догрузил всё до последнего пикселя». Отсчёт идёт от начала навигации.

LCPОценкаЧто чувствует человек
до 2,5 схорошо«открылось сразу»
2,5–4,0 снадо улучшать«грузится, но терпимо»
больше 4,0 сплохо«наверное, сломалось» — и уходит

Важно понимать, что LCP — не одно событие, а сумма четырёх отрезков. Пока вы не знаете, какой из них съедает время, любая оптимизация — стрельба вслепую:

  • TTFB (Time To First Byte) — сервер думал и отдал первый байт HTML. Тут живут медленные запросы к базе и холодный кеш CDN.
  • Resource load delay — пауза между приходом HTML и стартом загрузки LCP-ресурса. Самая обидная потеря: браузер просто поздно узнал о картинке (она пришла из CSS-фона, из JS или стояла с loading="lazy").
  • Resource load duration — сама загрузка картинки: вес, формат, канал.
  • Element render delay — ресурс уже в памяти, но отрисовать его мешает, например, блокирующий скрипт или шрифт.

Снять LCP прямо в браузере можно через PerformanceObserver — это API, на котором построены все RUM-библиотеки.

// LCP: браузер по мере загрузки предлагает несколько «кандидатов»,
// нам нужен последний — он и есть итоговый.
const po = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const last = entries[entries.length - 1];
  console.log('LCP:', Math.round(last.startTime), 'мс');
  console.log('элемент:', last.element); // покажет, ЧТО именно является LCP
});

// buffered: true — отдать записи, случившиеся до подписки
po.observe({ type: 'largest-contentful-paint', buffered: true });

// LCP «замораживается» на первом взаимодействии пользователя
addEventListener('keydown', () => po.disconnect(), { once: true });
addEventListener('click', () => po.disconnect(), { once: true });

Разберём построчно. list.getEntries() отдаёт всех кандидатов, накопленных к этому моменту; итоговый LCP — всегда последний, потому что по мере загрузки «самый крупный элемент» может смениться (сначала заголовок, потом приехала картинка героя). buffered: true обязателен: без него вы пропустите события, произошедшие до того, как выполнился ваш скрипт. И самое полезное — last.element: он покажет DOM-узел, из-за которого ваш LCP такой. Очень часто это оказывается не то, что вы думали.

INP — Interaction to Next Paint

INP (отклик до следующей отрисовки) — метрика отзывчивости. С марта 2024 года она заменила старый FID. FID мерил только задержку до начала обработки первого клика: он почти у всех был зелёным и почти ничего не говорил. INP честнее — он берёт полный путь от нажатия до кадра, в котором человек увидел реакцию, и делает это для всех взаимодействий за визит: клики, тапы, нажатия клавиш. Скролл и наведение не считаются.

INPОценкаЧто чувствует человек
до 200 мсхорошоинтерфейс «прилипает» к пальцу
200–500 мснадо улучшатьзаметная задумчивость
больше 500 мсплохо«кнопка не работает» — и жмут второй раз

Итоговое значение — практически «худшее взаимодействие на странице». Если взаимодействий меньше 50, берётся максимум; дальше на каждые 50 взаимодействий отбрасывается один самый плохой выброс. То есть спрятаться за средним не выйдет: один тормозящий фильтр каталога испортит метрику всей страницы.

Каждое взаимодействие раскладывается на три фазы:

  • Input delay — событие уже произошло, но главный поток занят чужой длинной задачей (обычно чьей-то аналитикой или гидратацией).
  • Processing time — работают ваши обработчики: setState, пересчёт, фильтрация массива.
  • Presentation delay — обработчики отработали, но браузер ещё считает стили, лейаут и рисует кадр.
// Смотрим на все взаимодействия и раскладываем их по фазам
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.interactionId) continue; // не настоящее взаимодействие — пропускаем

    const inputDelay   = entry.processingStart - entry.startTime;
    const processing   = entry.processingEnd - entry.processingStart;
    const presentation = entry.startTime + entry.duration - entry.processingEnd;

    console.log(entry.name, Math.round(entry.duration) + ' мс', {
      inputDelay: Math.round(inputDelay),
      processing: Math.round(processing),
      presentation: Math.round(presentation),
    });
  }
}).observe({ type: 'event', durationThreshold: 40, buffered: true });

Поле interactionId — фильтр: у «настоящих» взаимодействий оно не равно нулю. durationThreshold: 40 отсекает мелочь, чтобы не топить консоль (по умолчанию порог 104 мс, минимально допустимый — 16). А главное — эти три числа сразу говорят, куда идти чинить: большой inputDelay лечится дроблением длинных задач, большой processing — вашим же кодом, большой presentation — упрощением DOM и стилей.

CLS — Cumulative Layout Shift

CLS (совокупный сдвиг макета) — безразмерное число, наказание за прыгающую вёрстку. Каждый неожиданный сдвиг оценивается как доля затронутой площади экрана, умноженная на относительную дистанцию переезда. Классика жанра: приехал баннер и уронил весь контент вниз; загрузился шрифт и текст перескочил; картинка без width и height внезапно заняла своё место — ровно в тот момент, когда палец уже летел к кнопке.

CLSОценка
до 0,1хорошо
0,1–0,25надо улучшать
больше 0,25плохо

Вопреки названию, складывается не всё подряд за жизнь страницы. Сдвиги группируются в «сессионные окна»: окно длится, пока паузы между сдвигами меньше 1 секунды, и не дольше 5 секунд суммарно. Итоговый CLS — сумма худшего окна. Это спасает бесконечные ленты от вечно растущего штрафа.

// Упрощённый CLS: сумма сдвигов без учёта сессионных окон.
// Для прода так делать не надо — есть библиотека web-vitals.
let cls = 0;

new PerformanceObserver((list) => {
  for (const shift of list.getEntries()) {
    // сдвиг в пределах 500 мс после клика/нажатия — это реакция на действие
    // пользователя (открыл аккордеон), он не штрафуется
    if (shift.hadRecentInput) continue;

    cls += shift.value;
    console.log('сдвиг', shift.value.toFixed(4), 'итого', cls.toFixed(4));
    console.log('виновники:', shift.sources.map((s) => s.node));
  }
}).observe({ type: 'layout-shift', buffered: true });

Главная ценность здесь — shift.sources: браузер прямо называет узлы, которые переехали. Не надо гадать, кто дёрнул вёрстку, — спросите у браузера.

Как это работает

Все три метрики считает сам браузер и отдаёт их через Performance API. Дальше начинается арифметика, которую важно не перепутать: страница получает оценку не по среднему, а по 75-му перцентилю реальных визитов. «Хорошо» означает: три четверти пользователей уложились в порог. Оставшаяся четверть — самая медленная — как раз и есть те, кого вы никогда не увидите на своём ноутбуке.

Писать обсерверы руками в бою никто не станет: у метрик много крайних случаев (bfcache, вкладка в фоне, сессионные окна CLS). В проде берут официальную библиотеку web-vitals.

import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals';

function send(metric) {
  const body = JSON.stringify({
    name: metric.name,       // 'LCP' | 'INP' | 'CLS' | 'TTFB'
    value: metric.value,     // миллисекунды (для CLS — безразмерное число)
    rating: metric.rating,   // 'good' | 'needs-improvement' | 'poor'
    id: metric.id,           // идентификатор загрузки страницы
    path: location.pathname, // ОБЯЗАТЕЛЬНО: иначе не поймёте, какой шаблон тормозит
  });

  // sendBeacon переживает закрытие вкладки, обычный fetch — нет
  navigator.sendBeacon('/rum', body);
}

onLCP(send);
onINP(send);
onCLS(send);
onTTFB(send);

Обратите внимание на path: без разбивки по шаблонам страниц метрики превращаются в бесполезное «средневзвешенное по больнице». И на navigator.sendBeacon: метрики финализируются в момент, когда страница уходит в фон, — обычный fetch в этот момент браузер просто отменит.

МетрикаПро что онаПорог «хорошо»Типичная причина провала
LCPскорость показа главного2,5 стяжёлая картинка героя, найденная поздно
INPотзывчивость на действия200 мсдлинные задачи JS на главном потоке
CLSвизуальная стабильность0,1картинки и баннеры без зарезервированного места

Рядом с ними живут вспомогательные метрики: TTFB и FCP (First Contentful Paint — первая любая отрисовка). Сами по себе они не Core Web Vitals, но именно они объясняют, почему LCP плохой.

Частые ошибки

  • Гнаться за баллом, а не за метрикой. Лабораторные 100 баллов и красный LCP у реальных пользователей — совершенно обычное сочетание. Про это следующий урок.
  • Смотреть среднее. Среднее прячет хвост. Пороги Core Web Vitals считаются по 75-му перцентилю, и ваши дашборды должны считать так же.
  • Мерить LCP по load. Событие load ждёт вообще всё, включая аналитику в подвале. Пользователю на это давно всё равно.
  • Считать, что CLS — это про первый экран. Сдвиг на 20-й секунде от лениво подгруженного блока штрафуется так же.
  • Мерить INP кликом по пустому месту. Проверять надо самые тяжёлые взаимодействия: фильтр каталога, открытие модалки, ввод в поиск.
  • Собирать метрики без разбивки по страницам. Общий график «по сайту» показывает, что всё стабильно плохо, но не показывает, где чинить.

Итоги

  • LCP — «когда стало видно главное», порог 2,5 с. Раскладывается на TTFB, задержку обнаружения, загрузку и отрисовку.
  • INP — «когда интерфейс ответил», порог 200 мс. Заменил FID, берёт почти худшее взаимодействие, делится на input delay / processing / presentation.
  • CLS — «насколько прыгает вёрстка», порог 0,1. Считается по худшему сессионному окну; shift.sources прямо называет виновников.
  • Все три снимаются в браузере через PerformanceObserver, но в проде используйте библиотеку web-vitals и navigator.sendBeacon.
  • Оценка ставится по 75-му перцентилю реальных визитов, а не по вашему ноутбуку и не по среднему.
Проверьте себя
1. Пользователь жалуется: «нажимаю на фильтр — кнопка секунду не реагирует». Какая метрика Core Web Vitals ухудшится?
ALCP — страница медленно показывает контент
BINP — страница медленно отвечает на взаимодействие
CCLS — вёрстка нестабильна
DTTFB — сервер долго отдаёт первый байт
2. Что показывает свойство element у записи largest-contentful-paint?
AПервый элемент, отрисованный на странице
BЭлемент, который сильнее всех сдвинул вёрстку
CDOM-узел, который и является текущим LCP-кандидатом
DЭлемент, по которому пользователь кликнул первым