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-му перцентилю реальных визитов, а не по вашему ноутбуку и не по среднему.