Данила (Dayfing)
Назад к публикациям
2 532 слов13 мин

Core Web Vitals в 2026 году: как исправить LCP, INP и CLS

Начинайте с полевых данных, а не с оценки Lighthouse. В Search Console или PageSpeed Insights найдите метрику, которая не проходит на 75-м перцентиле, и тип устройств, где это происходит, а затем подключите attribution-сборку web-vitals, чтобы реальные сессии показали виновный элемент, взаимодействие или сдвиг. LCP исправляйте по самой большой составляющей, INP по длинной задаче за медленными взаимодействиями, CLS резервированием места для всего, что двигается. Лабораторные инструменты воспроизводят проблему и проверяют исправление, но не решают, проходите ли вы оценку.

Три метрики и их пороги

Обзор Web Vitals на web.dev называет три стабильные метрики Core Web Vitals:

  • Largest Contentful Paint (LCP), загрузка: хорошо при 2,5 секунды и меньше, плохо выше 4 секунд.
  • Interaction to Next Paint (INP), отзывчивость: хорошо при 200 миллисекундах и меньше, плохо выше 500 миллисекунд.
  • Cumulative Layout Shift (CLS), визуальная стабильность: хорошо при 0,1 и меньше, плохо выше 0,25.

Значения между границами получают оценку «требует улучшения». Пороги применяются к 75-му перцентилю загрузок страниц отдельно для мобильных и десктопных устройств, и страница проходит оценку, только если все три метрики хорошие на этом перцентиле. Приличная медиана может скрывать четверть визитов с медленных телефонов, а оценка существует как раз для того, чтобы их заметить.

INP заменил First Input Delay в 2024 году. Он отслеживает клики, касания и нажатия клавиш в течение всей жизни страницы и измеряет каждое взаимодействие до отрисовки следующего кадра. Наведение, масштабирование и прокрутка не учитываются, а на каждые 50 взаимодействий отбрасывается одно самое медленное, поэтому единичный выброс не определяет результат.

README web-vitals указывает, что onLCP() и onINP() работают в Chromium, Firefox и Safari, а onCLS() только в Chromium. Данные совместимости MDN показывают, что Safari добавил API LCP и Event Timing в версии 26.2. Полевой набор данных Google по-прежнему собирается только с пользователей Chrome.

Полевые данные против лабораторных

Полевые данные приходят от реальных визитов. Chrome UX Report (CrUX) агрегирует данные подходящих пользователей Chrome за скользящее окно в 28 дней, на уровне origin и URL, для страниц с достаточным публичным трафиком. PageSpeed Insights показывает эти данные в начале отчета и переходит к данным origin, если у URL их слишком мало. Отчет Core Web Vitals в Search Console использует тот же источник, группирует похожие URL, присваивает группе статус худшей метрики и отдельно отслеживает мобильные и десктопные устройства. Исправление, выкаченное сегодня, сдвигает эти цифры постепенно, в течение четырех недель.

Лабораторные данные получаются из одной контролируемой загрузки. Lighthouse загружает страницу на эмулированном устройстве и сети. Панель Performance в DevTools показывает локальные LCP, CLS и INP в реальном времени, записывает взаимодействия с их фазами и сдвиги макета с их оценками, а также умеет подтянуть данные CrUX с рекомендуемым замедлением CPU и сети, похожим на условия ваших пользователей.

Расхождения предсказуемы, и web.dev разбирает их в статье о различиях лабораторных и полевых данных:

  • LCP: лабораторный прогон идет с холодным кэшем, одним размером окна и без персонализации. У реальных пользователей ресурсы могут быть в кэше, элемент LCP может быть другим, может попасться вариант A/B-теста, а восстановления из bfcache тоже попадают в полевые данные.
  • INP: у навигационного прогона Lighthouse нет взаимодействий, поэтому вместо INP он показывает Total Blocking Time. TBT помогает найти блокировки во время загрузки, но не видит медленный клик позже в сессии.
  • CLS: лаборатория видит сдвиги во время загрузки. Полевой CLS охватывает всю жизнь страницы, включая ленивый контент без размеров и поздние рекламные блоки.

Решайте, что сломано, по полевым данным, а затем воспроизводите проблему в лаборатории с реалистичным замедлением. CrUX API сразу возвращает 75-й перцентиль и обновляется ежедневно:

curl -s --request POST \
  "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "origin": "https://example.com",
    "formFactor": "PHONE",
    "metrics": [
      "largest_contentful_paint",
      "interaction_to_next_paint",
      "cumulative_layout_shift",
      "largest_contentful_paint_image_time_to_first_byte",
      "largest_contentful_paint_image_resource_load_delay",
      "largest_contentful_paint_image_resource_load_duration",
      "largest_contentful_paint_image_element_render_delay"
    ]
  }'

Последние четыре метрики показывают составляющие LCP для загрузок, где элементом LCP является изображение.

Сбор данных реальных пользователей с attribution-сборкой

CrUX сообщает, что метрика не проходит, но редко объясняет почему. Библиотека web-vitals измеряет метрики так же, как Chrome, а ее attribution-сборка добавляет элемент, разбивку по времени и состояние загрузки документа. Актуальная мажорная версия сейчас шестая. Она умеет сообщать о soft navigations в одностраничных приложениях на Chromium 151 и новее, но как CrUX будет их учитывать, пока не решено.

npm install web-vitals

Этот модуль хранит последнюю запись для каждого экземпляра метрики и отправляет пакет через navigator.sendBeacon(), когда страница становится скрытой, как рекомендует README:

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

const queue = new Map();

function toRecord(metric) {
  const {name, value, rating, id, attribution} = metric;
  const record = {name, value, rating, id, page: metric.navigationURL ?? location.href};

  if (name === 'LCP') {
    record.target = attribution.target;
    record.ttfb = attribution.timeToFirstByte;
    record.loadDelay = attribution.resourceLoadDelay;
    record.loadDuration = attribution.resourceLoadDuration;
    record.renderDelay = attribution.elementRenderDelay;
  } else if (name === 'INP') {
    record.target = attribution.interactionTarget;
    record.inputDelay = attribution.inputDelay;
    record.processing = attribution.processingDuration;
    record.presentation = attribution.presentationDelay;
    record.loadState = attribution.loadState;
    record.script = attribution.longestScript?.entry.sourceURL;
  } else if (name === 'CLS') {
    record.target = attribution.largestShiftTarget;
    record.loadState = attribution.loadState;
  }
  return record;
}

function flush() {
  if (queue.size === 0) return;
  navigator.sendBeacon('/rum', JSON.stringify([...queue.values()]));
  queue.clear();
}

onLCP((metric) => queue.set(metric.id, toRecord(metric)));
onINP((metric) => queue.set(metric.id, toRecord(metric)));
onCLS((metric) => queue.set(metric.id, toRecord(metric)));

addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});

CLS и INP могут приходить несколько раз, поэтому сборщик хранит последнее значение для каждого id. INP отсутствует, если пользователь ни разу не взаимодействовал со страницей. Агрегируйте данные по шаблону страницы и типу устройства, затем по target, чтобы найти несколько элементов, которые определяют p75. Ваши цифры не совпадут с CrUX точно: в них есть другие браузеры, а библиотека не видит содержимое iframe. На нагруженных сайтах используйте выборку, держите низкую кардинальность меток и применяйте обычные правила хранения. Руководство по наблюдаемости AI-агентов описывает ту же дисциплину работы с телеметрией.

LCP: найдите медленную составляющую

Руководство по оптимизации LCP делит LCP на четыре последовательные части:

  1. Time to first byte (TTFB): от начала навигации до первого байта HTML.
  2. Resource load delay: от TTFB до начала запроса ресурса LCP.
  3. Resource load duration: сама загрузка ресурса.
  4. Element render delay: от готовности ресурса до отрисовки элемента.

Для текстового элемента LCP две средние части равны нулю. Руководство предлагает ориентир: примерно по 40 процентов на TTFB и загрузку ресурса и меньше 10 процентов на каждую задержку. Для 2,5 секунды это около 1 секунды, 250 миллисекунд, 1 секунды и 250 миллисекунд. Большая задержка загрузки означает позднее обнаружение ресурса. Большая задержка отрисовки означает, что что-то мешало рисовать. Сжатие изображения не исправит ни то, ни другое.

Исправления LCP, которые меняют цифру

Time to first byte

web.dev считает хорошим TTFB до 0,8 секунды и плохим выше 1,8 секунды. В него входят редиректы, запуск service worker, DNS, соединение и TLS, а также сам запрос. Уберите цепочки редиректов, кэшируйте HTML на границе CDN для анонимного трафика и отдавайте ассеты с хэшем в имени с долгим неизменяемым сроком кэширования. Руководство по кэшированию статики в Nginx и Cloudflare разбирает заголовки и правила. Если origin работает на небольшом VPS, держите его минимальным по чек-листу защиты Linux VPS и позвольте edge принимать трафик.

curl -s -o /dev/null \
  -w 'dns %{time_namelookup}\nconnect %{time_connect}\ntls %{time_appconnect}\nttfb %{time_starttransfer}\n' \
  https://example.com/

curl -sI https://example.com/ | grep -iE '^(cache-control|age|cf-cache-status):'

Resource load delay

Изображение LCP должно начинать загружаться вместе с первыми ресурсами страницы. Поместите его в исходный HTML как <img>, никогда не ставьте ему loading="lazy" и добавьте fetchpriority="high", который MDN отмечает в Chromium, Firefox 132+ и Safari 17.2+. Обычная причина проблемы в hero-блоке, который рендерится на клиенте: изображение нельзя запросить, пока бандл не загрузится, не выполнится и часто не получит данные. Рендерьте hero на сервере или заранее. Изображение LCP из CSS загружайте через preload.

<link rel="preload" as="image" href="/img/hero-bg.avif"
      type="image/avif" fetchpriority="high">

<picture>
  <source type="image/avif"
          srcset="/img/hero-800.avif 800w, /img/hero-1600.avif 1600w"
          sizes="(max-width: 800px) 100vw, 800px">
  <img src="/img/hero-800.jpg"
       srcset="/img/hero-800.jpg 800w, /img/hero-1600.jpg 1600w"
       sizes="(max-width: 800px) 100vw, 800px"
       width="800" height="450" alt="Product dashboard"
       fetchpriority="high">
</picture>

<link> нужен только для изображения из CSS, а <picture> для обычного контента.

Resource load duration

Отправляйте меньше байтов. Отдавайте AVIF или WebP с запасным форматом, позвольте srcset и sizes подобрать файл под реальный размер слота и раздавайте изображения через CDN со сроком кэширования, при котором повторный визит обходится без загрузки. Держите мало запросов с высоким приоритетом, потому что каждый из них конкурирует с изображением LCP.

Element render delay

Если изображение пришло рано, а отрисовалось поздно, ищите большие блокирующие стили, синхронные скрипты в <head> и шрифты с font-display: block или auto. Скрипты, которые скрывают страницу до своего завершения, как делают некоторые инструменты экспериментов, добавляют свое время работы к LCP. Встраивайте критический CSS, откладывайте остальное и отрисовывайте hero без клиентского JavaScript.

INP: найдите взаимодействие и его длинную задачу

У взаимодействия три фазы. Input delay это ожидание до запуска обработчиков, обычно за другой задачей. Processing duration это работа самих обработчиков. Presentation delay длится от конца обработчиков до следующего кадра и включает стили, раскладку и отрисовку.

Читайте их вместе. Большой input delay при loadState, равном dom-interactive или dom-content-loaded, означает, что пользователь нажал, пока страница загружалась, обычно во время гидратации или сторонних скриптов. Большой processing указывает на обработчик. Большой presentation указывает на большой DOM или принудительную раскладку. В Chromium поле longestScript из Long Animation Frames API называет скрипт, который работал дольше всех.

Для воспроизведения откройте панель Performance, включите предложенное замедление CPU и повторите взаимодействие с элементом из target в RUM. Любая задача дольше 50 миллисекунд считается длинной, и взаимодействие, вставшее за ней в очередь, получает ее оставшееся время.

Исправления INP: меньше работы на взаимодействие

Сначала отрисуйте ответ

Сделайте минимум, который дает видимую обратную связь, позвольте браузеру отрисовать кадр и только потом продолжайте. Аналитике и автосохранению редко нужно завершиться до следующего кадра. Руководство по длинным задачам приводит такой помощник:

function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }
  return new Promise((resolve) => {
    setTimeout(resolve, 0);
  });
}

filterButton.addEventListener('click', async () => {
  filterButton.setAttribute('aria-busy', 'true');
  await yieldToMain();

  const rows = filterRows(allRows, currentQuery());
  renderRows(rows);
  filterButton.removeAttribute('aria-busy');

  await yieldToMain();
  sendAnalytics('filter', rows.length);
});

async function processInChunks(items, handleItem, budgetMs = 40) {
  let lastYield = performance.now();
  for (const item of items) {
    handleItem(item);
    if (performance.now() - lastYield > budgetMs) {
      await yieldToMain();
      lastYield = performance.now();
    }
  }
}

Уступайте поток через scheduler.yield там, где он есть

scheduler.yield() продолжает ваш код как приоритетное продолжение, раньше других задач с похожим приоритетом в очереди. MDN отмечает ограниченную доступность: Chrome и Edge 129+, Firefox 142+, в Safari поддержки нет. Проверка наличия API с запасным вариантом на setTimeout() обязательна. Запасной вариант тоже делит задачу, но теряет приоритет. web.dev больше не рекомендует isInputPending() и советует уступать поток в любом случае. Помощник для обработки частями держит каждую задачу в пределах 40 миллисекунд, поэтому касание посреди цикла ждет только одну короткую задачу.

Гидратация и острова

Гидратация всей страницы выполняет все дерево компонентов во время загрузки, включая статический контент. Взаимодействия в это время получают большие задержки ввода. Рендерьте статический контент как HTML, гидратируйте только интерактивные компоненты и откладывайте их до простоя браузера или появления на экране. В Astro директивы задают момент гидратации каждого острова:

---
import SearchBox from '../components/SearchBox.jsx';
import Comments from '../components/Comments.jsx';
import PriceChart from '../components/PriceChart.jsx';
---
<SearchBox client:idle={{ timeout: 2000 }} />
<Comments client:visible={{ rootMargin: "200px" }} />
<PriceChart client:media="(min-width: 960px)" />

Компоненты без директивы не отправляют JavaScript. React Server Components и частичная гидратация в других фреймворках стремятся к тому же результату, но его нужно измерять, а не предполагать. Проверьте и сторонние скрипты: удалите неиспользуемые теги, остальные загружайте в простое браузера и следите за их URL в longestScript.

Presentation delay

Стоимость отрисовки растет вместе с размером DOM. Держите DOM компактным, используйте content-visibility: auto для длинных разделов за пределами экрана и не читайте offsetHeight сразу после записи стилей, потому что это вызывает синхронную раскладку. Тяжелый разбор или сортировку данных переносите в Web Worker.

CLS: резервируйте место и стабилизируйте шрифты

Оценка сдвига макета равна произведению доли затронутой области на долю расстояния. Сдвиги с интервалом меньше 1 секунды объединяются в сессионное окно длиной до 5 секунд, и CLS равен самому большому окну. Сдвиги в течение 500 миллисекунд после касания или нажатия клавиши не учитываются. Руководство по оптимизации CLS перечисляет типичные причины:

  • Медиа без размеров: задайте width и height или aspect-ratio, чтобы место было зарезервировано.
  • Реклама, встраиваемые блоки и баннеры: резервируйте слот через min-height и никогда не вставляйте поздний контент над тем, что читает пользователь.
  • Веб-шрифты: используйте font-display: optional или swap с запасным шрифтом, настроенным через size-adjust и переопределения ascent, descent и line-gap, и загружайте критичные шрифты через preload.
  • Анимации: анимируйте transform и opacity, а не top, left, width или height. Композитные трансформации не учитываются в CLS.
  • Навигация назад и вперед: восстановление из bfcache показывает загруженную страницу без сдвигов, поэтому сохраняйте страницы пригодными для него.
.ad-slot {
  min-height: 250px;
}

@font-face {
  font-family: "Brand Sans Fallback";
  src: local("Arial");
  size-adjust: 104%;
  ascent-override: 92%;
  descent-override: 24%;
  line-gap-override: 0%;
}

body {
  font-family: "Brand Sans", "Brand Sans Fallback", sans-serif;
}

.toast {
  transform: translateY(100%);
  transition: transform 200ms ease-out;
}

.toast.is-visible {
  transform: translateY(0);
}

Проценты переопределений здесь условные. Вычислите их по метрикам вашего реального шрифта и сравните оба шрифта в браузере.

Что исправлять в первую очередь

Начните с метрики, которая не проходит в группах URL с наибольшим трафиком, на мобильных устройствах, если десктоп не является вашей основной аудиторией. Внутри метрики работайте с составляющей или фазой, у которой самая большая доля на p75. Сначала выкатывайте дешевые исправления: fetchpriority, отказ от ленивой загрузки hero и размеры изображений занимают минуты, а острова или серверный рендеринг требуют запланированной миграции.

Сигнал в поле Вероятная причина Первое исправление Чем подтвердить
LCP, доминирует TTFB HTML без кэша, редиректы Кэш на edge, без цепочек редиректов Тайминги curl
LCP, доминирует задержка загрузки Позднее обнаружение, ленивый hero, клиентский рендеринг <img> в HTML, fetchpriority Сетевой waterfall
LCP, доминирует длительность загрузки Слишком большое изображение, старый формат AVIF или WebP, srcset, CDN lcpResourceEntry
LCP, доминирует задержка отрисовки Блокирующие CSS, скрипты или шрифты Критический CSS, отложенные скрипты Трейс Performance
INP, задержка ввода при загрузке Гидратация, сторонние теги Острова, отложенная гидратация loadState
INP, долгая обработка Тяжелый обработчик Сначала отрисовка, yield, worker longestScript
INP, долгая презентация Большой DOM, принудительная раскладка Меньший DOM, content-visibility Таблица взаимодействий
CLS во время загрузки Медиа без размеров, смена шрифта Размеры, настроенный запасной шрифт largestShiftTarget
CLS после загрузки Поздний контент, анимации раскладки Зарезервированные слоты, transform Вкладка сдвигов макета

Проверка исправления и защита от регрессий

Подтверждайте каждое изменение дважды. В лаборатории сравните трейсы Performance до и после с замедлением по полевым данным и убедитесь, что нужная составляющая уменьшилась. В поле следите за p75 в своем RUM для затронутого шаблона, который реагирует за несколько дней, и дайте CrUX догнать изменения за 28 дней. Затем нажмите «Начать отслеживание» в Search Console, чтобы запустить 28-дневную проверку.

Держите бюджет Lighthouse в CI, чтобы ловить новые блокирующие скрипты, изображения без размеров и скачки Total Blocking Time. Он не видит реальных взаимодействий, поэтому настройте и оповещение по полевому INP, а релизы отмечайте на дашборде RUM.

Чек-лист

  • Изучите полевые данные в Search Console и PageSpeed Insights для мобильных и десктопа.
  • Запишите, какая метрика не проходит на p75 в группах URL с наибольшим трафиком.
  • Подключите attribution-сборку web-vitals и отправляйте beacon при visibilitychange.
  • Агрегируйте RUM по шаблону, типу устройства и target.
  • Найдите доминирующую составляющую LCP до выбора исправления.
  • Поместите hero в HTML с fetchpriority="high", без ленивой загрузки и с правильным srcset.
  • Кэшируйте HTML на edge и держите TTFB в пределах 0,8 секунды.
  • Делите длинные обработчики через scheduler.yield() с запасным setTimeout().
  • Гидратируйте только интерактивные острова и проверяйте сторонние скрипты.
  • Резервируйте место для медиа, рекламы и баннеров и настраивайте запасные шрифты.
  • Проверяйте исправления в замедленном трейсе, затем в RUM, затем в CrUX.

Ещё публикации