Клавиша / esc

INP (Interaction to Next Paint)

Что такое метрика Interaction to Next Paint в Core Web Vitals

Время чтения: больше 15 мин

Что такое INP

Скопировано

INP (Interaction to Next Paint) измеряет время от действия пользователя — клика, тапа, нажатия клавиши — до момента, когда браузер отрисовывает следующий кадр с видимым откликом на это действие. Хорошим считается значение до 200 мс, приемлемым — до 500 мс, всё, что больше, — плохой результат.

Медленная реакция интерфейса создаёт впечатление, что действие не было выполнено. В результате пользователь может повторно выполнить то же действие, прервать заполнение формы или отказаться от дальнейшего взаимодействия со страницей. Такие задержки особенно критичны в поиске, корзине, процессе оплаты и других ключевых пользовательских сценариях.

Пример сценария: пользователь нажимает кнопку «Добавить в корзину», но счётчик товаров обновляется только через полторы секунды. Не получив своевременного визуального подтверждения, пользователь нажимает кнопку повторно. В результате в корзину добавляются две одинаковые позиции вместо одной.

Чем INP отличается от FID

Скопировано

До марта 2024 года за отзывчивость отвечала метрика FID (First Input Delay). У неё было два существенных недостатка: она учитывала только первое взаимодействие пользователя со страницей и измеряла лишь задержку до начала обработки события, игнорируя время выполнения обработчика и последующей отрисовки. Поэтому FID не всегда отражала реальную отзывчивость интерфейса.

INP пришла на смену FID и учитывает все клики, касания и нажатия клавиш за всё время, пока страница открыта. Итоговое значение обычно близко к самому медленному взаимодействию, хотя при большом количестве взаимодействий отдельные выбросы могут быть исключены из расчёта. В отличие от FID, INP измеряет всё время от действия пользователя до появления визуального отклика.

Из чего складывается INP

Скопировано
INP складывается из трёх частей: задержки ввода, времени обработки и задержки отрисовки
  1. задержка ввода (input delay) — время от действия пользователя до начала выполнения обработчика события. Увеличивается, если главный поток в этот момент занят другими задачами (чаще всего выполнением JavaScript);
  2. время обработки (processing duration) — время выполнения обработчика события и другой синхронной работы, нужной для обработки взаимодействия;
  3. задержка отрисовки (presentation delay) — время от завершения обработки события до отрисовки кадра с обновлённым интерфейсом.

Что ухудшает INP

Скопировано
  • длинные задачи (Long Tasks) на главном потоке — пока главный поток выполняет задачу дольше 50 мс, он не может начать обработку нового взаимодействия. Это одна из основных причин высокого INP;
  • тяжёлые обработчики событий, которые выполняют большой объём вычислений или инициируют дорогостоящее обновление интерфейса;
  • layout thrashing — чередование записи и чтения данных о раскладке (например, изменение стилей → чтение offsetHeight → новое изменение стилей), из-за которого браузер многократно пересчитывает layout;
  • большой DOM — чем больше узлов на странице, тем дороже становятся расчёт раскладки, отрисовка и обновление интерфейса;
  • большие обновления DOM на клиенте — если взаимодействие приводит к перестроению значительной части дерева DOM, браузеру требуется больше времени на layout, paint и отрисовку нового кадра.

Как улучшать отзывчивость

Скопировано

Дальше — конкретные приёмы: с чего начать разбор бандла, как избежать лишней работы на главном потоке и на что обратить внимание в React-приложениях.

Снижайте размер бандла

Скопировано

Прежде чем добавлять сложные решения вроде scheduler.yield() или Web Worker'ов, стоит начать с одной из главных причин проблем — объёма JavaScript, который получает браузер. Каждый килобайт скрипта нужно загрузить, разобрать и выполнить, причём большая часть этой работы происходит на главном потоке. В результате поток, который отвечает за взаимодействие с пользователем, может быть перегружен и не успевать обрабатывать действия вроде кликов и ввода.

Большой JavaScript-бандл ухудшает сразу несколько метрик производительности: увеличивает нагрузку на главный поток, из-за чего страдает INP, а также может задерживать появление основного контента и ухудшать LCP (Largest Contentful Paint, загрузку основного контента). Поэтому оптимизацию производительности обычно начинают с анализа и сокращения клиентского JavaScript: удаляют неиспользуемый код, разделяют бандл на части и загружают только то, что действительно нужно пользователю сейчас. Способы — по возрастанию сложности.

Сначала измерьте

Скопировано

Не оптимизируйте вслепую — посмотрите, из чего состоит бандл. webpack-bundle-analyzer или @next/bundle-analyzer рисуют интерактивную карту, где сразу видно самые большие пакеты.

Делите бандл (code splitting)

Скопировано

Разделяйте код по роутам (каждая страница должна загружать только свой код) и по компонентам. То, что не нужно на первом экране, грузите лениво — через динамический import() или React.lazy в чистом React или через next/dynamic в Next.js. Тяжёлый график, редактор, модалка, админка — всё это пусть подгружается, когда до него дойдёт дело, а не на старте:

        
          
          import dynamic from "next/dynamic";const HeavyChart = dynamic(() => import("./heavy-chart"), { ssr: false });
          import dynamic from "next/dynamic";

const HeavyChart = dynamic(() => import("./heavy-chart"), { ssr: false });

        
        
          
        
      

Включите tree shaking

Скопировано

Сборщик сможет удалить неиспользуемый код, если библиотека поддерживает tree shaking и вы импортируете только нужные модули. import { debounce } from 'lodash-es' вместо import _ from 'lodash', точечные импорты из date-fns вместо подключения целой библиотеки вроде moment, или нативные аналоги. Импорт «всего сразу» обычно убивает tree shaking.

Берегитесь barrel-файлов

Скопировано

Удобный index.ts, который реэкспортирует всё из папки (export * from './...'), может помешать tree shaking: импортируете одну кнопку — загружается вся UI-библиотека. В Next.js это лечит опция optimizePackageImports: импорты разворачиваются в точечные автоматически.

Выносите вычисления на сервер

Скопировано

Самый большой клиентский бандл часто делает работу, которой там не место, например, рендер Markdown. В Next.js App Router это уносится в серверные компоненты, и объём клиентского JavaScript уменьшается. Дальше — замените тяжёлые зависимости на лёгкие аналоги, сожмите ответ (Brotli) и поставьте бюджет размера бандла в CI, чтобы он не рос незаметно от релиза к релизу.

Лучший JavaScript — тот, который не пришлось отправлять в браузер. Прежде чем оптимизировать выполнение скрипта, спросите, нужен ли он на этой странице вообще и нельзя ли сделать то же самое разметкой, CSS или выполнить на сервере.

Другие приёмы

Скопировано

Применяйте дебаунсинг к частым событиям (input, resize, scroll): не запускайте тяжёлую логику при каждом событии. Особенно актуально для автокомплита: без дебаунса обработчик выполняется на каждое нажатие клавиши, и в результате браузеру приходится обрабатывать множество ненужных для него ответов. Для событий, которые нужно ограничить по частоте, а не отложить до паузы (скролл, resize), больше подходит троттлинг.

Отменяйте устаревшие запросы через AbortController. Если пользователь быстро печатает в поле поиска, каждое изменение может отправлять новый запрос через fetch(). Отменяйте предыдущий запрос перед отправкой следующего, чтобы не обрабатывать ответы, которые уже потеряли актуальность, избежать гонок между запросами и уменьшить объём лишней работы на главном потоке.

        
          
          let controller;input.addEventListener("input", async (e) => {  const query = e.target.value.trim();  controller?.abort();  if (!query) {    return;  }  controller = new AbortController();  const res = await fetch(`/search?q=${encodeURIComponent(query)}`, {    signal: controller.signal,  });  // ...});
          let controller;

input.addEventListener("input", async (e) => {
  const query = e.target.value.trim();

  controller?.abort();

  if (!query) {
    return;
  }

  controller = new AbortController();

  const res = await fetch(`/search?q=${encodeURIComponent(query)}`, {
    signal: controller.signal,
  });

  // ...
});

        
        
          
        
      

Осторожнее с таймерами: setInterval мешает интерактивности сильнее, чем разовый setTimeout, потому что продолжает регулярно запускать работу, пока его явно не остановят. Проверяйте, не остались ли в приложении забытые setInterval (частый источник — счётчики, поллинг, автосохранение), и переносите тяжёлую работу из таймеров в Web Worker.

Анимируйте через CSS, а не requestAnimationFrame, там, где это возможно: браузер сможет выполнить её без участия главного потока, если анимируются только transform и opacity — свойства, которые браузер обсчитывает на этапе композиции, минуя главный поток. Если JS-анимация всё же нужна, следите, чтобы она не переводила вычисления обратно на главный поток (не анимируйте width/top/box-shadow, смотрите также приёмы для CLS).

Не рендерите большие куски HTML на клиенте через innerHTML или его аналоги. В отличие от серверного HTML, который браузер может разбирать и отображать потоково, клиентская вставка не нарезается автоматически: парсинг и вставка большой разметки выполняются как одна длинная задача и блокируют кадр. Плюс ресурсы (картинки, скрипты) внутри такой разметки не увидит предзагрузочный сканер браузера (механизм, который заранее вычитывает разметку и начинает качать ресурсы, не дожидаясь парсера) — а значит, это уже затрагивает LCP. Максимизируйте серверный рендеринг (SSR, отрисовка HTML на сервере при каждом запросе), статическую генерацию (SSG, HTML собирается заранее на этапе сборки) или серверные компоненты React (RSC) и старайтесь, чтобы объём клиентской отрисовки оставался небольшим.

Не делайте layout thrashing: не чередуйте чтение и запись геометрии в одном обработчике. Если после изменения стиля тут же прочитать offsetHeight, браузер вынужден немедленно пересчитать layout (forced synchronous layout), вместо того чтобы сделать это один раз в конце кадра. Простое правило: сначала выполните все чтения геометрии, затем — все изменения DOM и стилей:

        
          
          // плохо: чтение и запись чередуются — layout считается на каждой итерацииboxes.forEach((box) => {  const height = box.offsetHeight; // чтение  box.style.height = `${height * 2}px`; // запись});// хорошо: сначала все чтения, потом все записи — layout считается один разconst heights = boxes.map((box) => box.offsetHeight);boxes.forEach((box, i) => {  box.style.height = `${heights[i] * 2}px`;});
          // плохо: чтение и запись чередуются — layout считается на каждой итерации
boxes.forEach((box) => {
  const height = box.offsetHeight; // чтение
  box.style.height = `${height * 2}px`; // запись
});

// хорошо: сначала все чтения, потом все записи — layout считается один раз
const heights = boxes.map((box) => box.offsetHeight);
boxes.forEach((box, i) => {
  box.style.height = `${heights[i] * 2}px`;
});

        
        
          
        
      

Уменьшайте размер дерева DOM и применяйте content-visibility: auto, чтобы сократить объём работы при расчёте стилей, layout и отрисовке. Lighthouse начинает предупреждать о большом DOM уже после 800 узлов, а после 1400 считает проблему серьёзной: чем больше DOM и глубже его вложенность, тем дороже становятся расчёт стилей, layout и обновление страницы. Помогают фрагменты вместо лишних обёрток (<>...</> в React), более плоская структура DOM без цепочек div > div > div, а также отложенная отрисовка скрытых секций (например, вкладок и аккордеонов) до момента, когда они действительно понадобятся.

Подробнее про content-visibility

content-visibility: auto откладывает расчёт стилей, layout и отрисовку содержимого за пределами экрана и выполняет их только тогда, когда элемент приближается к вьюпорту. На длинных страницах (ленты, документация с якорями) это может заметно ускорить рендер: в одном из примеров Google время сократилось с 232 до 30 мс.

Чтобы избежать скачков макета, обязательно задавайте contain-intrinsic-size. Пока содержимое ещё не отрисовано, браузер использует это значение как предполагаемый размер элемента.

        
          
          .section {  content-visibility: auto;  contain-intrinsic-size: auto 1000px;}
          .section {
  content-visibility: auto;
  contain-intrinsic-size: auto 1000px;
}

        
        
          
        
      

Есть и второе значение — content-visibility: hidden. В отличие от display: none, оно скрывает содержимое, но сохраняет результаты предыдущих расчётов layout и paint, поэтому повторное отображение обычно обходится дешевле. Это полезно для скрытых вкладок, виртуального скролла и других сценариев, где содержимое часто показывается и скрывается.

Из подводных камней:

  • вызов DOM-методов вроде getBoundingClientRect() или offsetHeight на скрытом поддереве принудительно запускает layout, сводя выигрыш на нет;
  • если скрытый контент не должен быть доступен скринридерам, добавляйте aria-hidden="true".

Упрощайте CSS-селекторы: пересчёт стилей требует проверить, какие селекторы подходят каждому элементу, поэтому длинная цепочка вроде .box:nth-last-child(-n+1) .title обычно обходится дороже, чем один класс .final-box-title (подробнее о том, как браузер сопоставляет селекторы — в статье про специфичность). На страницах с большим DOM и частыми обновлениями интерфейса это помогает уменьшить стоимость пересчёта стилей и улучшить INP.

Дробите большие скрипты: разбор и выполнение большого JavaScript-файла легко превращаются в длинную задачу, которая блокирует главный поток, даже если код-сплиттинг из раздела про размер бандла уже применён. Ориентир — около 100 КБ на файл: ES-модули (type="module") сами по себе грузятся и парсятся отдельными кусками, благодаря чему браузер успевает обработать пользовательский ввод между ними.

На мобильных устройствах задавайте touch-action: manipulation для кнопок и других интерактивных элементов. Свойство отключает ожидание второго тапа для масштабирования и тем самым убирает историческую задержку около 300 мс перед обработкой нажатия. Поддерживается всеми современными браузерами.

Если вы используете React

Скопировано

Используйте конкурентные возможности React — useTransition и useDeferredValue, чтобы помечать тяжёлые обновления как несрочные и не блокировать срочные взаимодействия пользователя. Во время конкурентного рендеринга React периодически уступает главный поток, поэтому ввод и клики не приходится ждать завершения большого обновления.

В SSR-приложениях дробите гидратацию через <Suspense>. Гидратация — это момент, когда React «оживляет» полученную с сервера статичную разметку, навешивая на неё обработчики событий и состояние. Чем гранулярнее границы <Suspense>, тем меньше компонентов React гидратирует синхронно за раз — главный поток блокируется на меньшее время. А серверные компоненты вообще не гидратируются: для них шаг гидратации пропускается, и это лучшее, что можно сделать для INP в React.

В React 19.2 прячьте неактивные табы, модалки и содержимое за пределами экрана через <Activity mode="hidden"> вместо условного рендера или display: none вручную. React не размонтирует компонент: состояние, DOM-узлы и позиция скролла сохраняются, но все useEffect внутри останавливаются, а сам узел выпадает из расчётов layout и paint у браузера — так что скрытый контент почти ничего не стоит браузеру, хотя формально остаётся в DOM. Контент можно заранее подготовить в mode="hidden", поэтому переключение на видимый режим происходит без фриза интерфейса.

Углублённая оптимизация

Скопировано

Разбивайте длинные задачи и уступайте главный поток. Главный приём — периодически уступать главный поток браузеру, чтобы он успевал обрабатывать нажатия. Современный способ — scheduler.yield():

        
          
          async function saveSettings() {  // То, что важно показать сразу:  validateForm();  showSpinner();  updateUI();  // Уступаем главный поток — браузер успеет ответить на действия:  await scheduler.yield();  // То, что пользователь не видит, — отдельной задачей:  saveToLocalStorage();  sendAnalytics();}
          async function saveSettings() {
  // То, что важно показать сразу:
  validateForm();
  showSpinner();
  updateUI();

  // Уступаем главный поток — браузер успеет ответить на действия:
  await scheduler.yield();

  // То, что пользователь не видит, — отдельной задачей:
  saveToLocalStorage();
  sendAnalytics();
}

        
        
          
        
      

У scheduler.yield() есть приятная особенность: продолжение после await встаёт в очередь с приоритетом — оно обычно выполняется раньше обычных задач из очереди благодаря механизму приоритетов Scheduler API (в отличие от setTimeout, который помещает продолжение в конец очереди; почитайте подробнее про микро- и макрозадачи, если хочется понять механику целиком). Он появился в Chrome 129 (сентябрь 2024), поддерживается Chromium-браузерами и Firefox начиная с версии 142. Но Safari пока не поддерживает, поэтому нужен фолбэк на setTimeout:

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

        
        
          
        
      

Для обработки больших объёмов данных режьте работу скрипта на куски и уступайте поток, если задача идёт дольше 50 мс:

        
          
          async function runJobs(jobQueue, deadline = 50) {  let lastYield = performance.now();  for (const job of jobQueue) {    job();    if (performance.now() - lastYield > deadline) {      await yieldToMain();      lastYield = performance.now();    }  }}
          async function runJobs(jobQueue, deadline = 50) {
  let lastYield = performance.now();
  for (const job of jobQueue) {
    job();
    if (performance.now() - lastYield > deadline) {
      await yieldToMain();
      lastYield = performance.now();
    }
  }
}

        
        
          
        
      

Не используйте navigator.scheduling.isInputPending() для проверки «не ждёт ли ввод» — разработчики Chrome и гайды web.dev не рекомендуют на неё полагаться: метод может вернуть false, хотя пользователь уже нажал. Вместо этого используйте scheduler.yield() (с фолбэком на setTimeout, см. yieldToMain() выше).

Сначала показать, потом досчитать: обновите видимый интерфейс, а тяжёлую работу перенесите в следующую задачу через await yieldToMain().

Выносите тяжёлые вычисления в Web Worker: он работает в отдельном потоке и не блокирует интерфейс. Общаться с воркером напрямую через postMessage/onmessage неудобно, поэтому обычно берут библиотеку Comlink — она заворачивает воркер в обычный объект с промисами, которые выглядят как обычные асинхронные методы, возвращающие Promise. Из ограничений — у воркера нет доступа к DOM, поэтому в нём место только чистым вычислениям (парсинг, сортировка больших массивов, обработка данных), а не UI-логике; и учтите накладные расходы на сериализацию сообщений: для мелких данных не имеет значения, а вот большие объекты (мегабайты) лучше прогонять через ArrayBuffer.

Сторонние скрипты и тег-менеджеры

Скопировано

По данным DebugBear, задержка отрисовки (presentation delay) составляет около 42% от INP, и один из крупнейших вкладов в неё вносят именно сторонние скрипты: теги-менеджеры с кучей тегов, A/B-тесты на клиентской части, чаты-виджеты, тепловые карты и аналитика. Они крутятся на главном потоке и блокируют отклик, особенно на мобильных.

Обычный Web Worker им не подходит: у воркеров нет доступа к DOM, а тег-менеджеры вроде Google Tag Manager (GTM) активно с ним работают. Решение — Partytown: библиотека запускает сторонний скрипт в воркере и прозрачно проксирует обращения к DOM обратно в главный поток. В Next.js подключается через next/script со стратегией worker, либо напрямую как библиотека — выбор момента загрузки стороннего кода задаётся значениями beforeInteractive, afterInteractive (по умолчанию), lazyOnload или worker. Грузите такие скрипты отложенно через lazyOnload и удаляйте то, что не приносит пользы. В отдельных проектах перенос GTM в Partytown позволял снизить TBT (Total Blocking Time, суммарное время блокировки главного потока) на 92%.

Внутри тег-менеджера тоже есть на чём экономить. По убыванию производительности: пиксели (простой GET/POST-запрос без выполнения JS после) → кастомные шаблоны (готовые заготовки тегов внутри тег-менеджера) → Custom HTML-теги (самые дорогие, вставляют разметку и форсируют layout). Не тащите через тег-менеджер то, что должно быть на экране сразу (баннер cookie, главную картинку первого экрана): тег-менеджер задерживает их доставку. Держите контейнер компактным (контейнер — весь набор тегов, который тег-менеджер подгружает на страницу одним файлом; у Google Tag Manager жёсткий лимит 300 КБ, обычный размер — около 50 КБ), убирайте неиспользуемые теги и переменные, не дублируйте один и тот же тег в разметке и в менеджере одновременно.

Для встроенных виджетов (карты, видео, соцсети) работает паттерн facade: сначала статичный превью-скриншот вместо живого эмбеда, preconnect при наведении и полноценная загрузка только по клику. lite-youtube-embed таким способом заметно ускоряется по сравнению с обычным <iframe> YouTube. Плюс loading="lazy" на <iframe>, если полноценный facade избыточен, и обязательные width/height, иначе догрузка виджета сдвигает макет так же, как незаданные размеры картинки.