Что такое INP
СкопированоINP (Interaction to Next Paint) измеряет время от действия пользователя — клика, тапа, нажатия клавиши — до момента, когда браузер отрисовывает следующий кадр с видимым откликом на это действие. Хорошим считается значение до 200 мс, приемлемым — до 500 мс, всё, что больше, — плохой результат.
Медленная реакция интерфейса создаёт впечатление, что действие не было выполнено. В результате пользователь может повторно выполнить то же действие, прервать заполнение формы или отказаться от дальнейшего взаимодействия со страницей. Такие задержки особенно критичны в поиске, корзине, процессе оплаты и других ключевых пользовательских сценариях.
Пример сценария: пользователь нажимает кнопку «Добавить в корзину», но счётчик товаров обновляется только через полторы секунды. Не получив своевременного визуального подтверждения, пользователь нажимает кнопку повторно. В результате в корзину добавляются две одинаковые позиции вместо одной.
Чем INP отличается от FID
СкопированоДо марта 2024 года за отзывчивость отвечала метрика FID (First Input Delay). У неё было два существенных недостатка: она учитывала только первое взаимодействие пользователя со страницей и измеряла лишь задержку до начала обработки события, игнорируя время выполнения обработчика и последующей отрисовки. Поэтому FID не всегда отражала реальную отзывчивость интерфейса.
INP пришла на смену FID и учитывает все клики, касания и нажатия клавиш за всё время, пока страница открыта. Итоговое значение обычно близко к самому медленному взаимодействию, хотя при большом количестве взаимодействий отдельные выбросы могут быть исключены из расчёта. В отличие от FID, INP измеряет всё время от действия пользователя до появления визуального отклика.
Из чего складывается INP
Скопировано- задержка ввода (input delay) — время от действия пользователя до начала выполнения обработчика события. Увеличивается, если главный поток в этот момент занят другими задачами (чаще всего выполнением JavaScript);
- время обработки (processing duration) — время выполнения обработчика события и другой синхронной работы, нужной для обработки взаимодействия;
- задержка отрисовки (presentation delay) — время от завершения обработки события до отрисовки кадра с обновлённым интерфейсом.
Что ухудшает INP
Скопировано- длинные задачи (Long Tasks) на главном потоке — пока главный поток выполняет задачу дольше 50 мс, он не может начать обработку нового взаимодействия. Это одна из основных причин высокого INP;
- тяжёлые обработчики событий, которые выполняют большой объём вычислений или инициируют дорогостоящее обновление интерфейса;
- layout thrashing — чередование записи и чтения данных о раскладке (например, изменение стилей → чтение
offset→ новое изменение стилей), из-за которого браузер многократно пересчитывает layout;Height - большой DOM — чем больше узлов на странице, тем дороже становятся расчёт раскладки, отрисовка и обновление интерфейса;
- большие обновления DOM на клиенте — если взаимодействие приводит к перестроению значительной части дерева DOM, браузеру требуется больше времени на layout, paint и отрисовку нового кадра.
Как улучшать отзывчивость
СкопированоДальше — конкретные приёмы: с чего начать разбор бандла, как избежать лишней работы на главном потоке и на что обратить внимание в React-приложениях.
Снижайте размер бандла
СкопированоПрежде чем добавлять сложные решения вроде scheduler или Web Worker'ов, стоит начать с одной из главных причин проблем — объёма JavaScript, который получает браузер. Каждый килобайт скрипта нужно загрузить, разобрать и выполнить, причём большая часть этой работы происходит на главном потоке. В результате поток, который отвечает за взаимодействие с пользователем, может быть перегружен и не успевать обрабатывать действия вроде кликов и ввода.
Большой JavaScript-бандл ухудшает сразу несколько метрик производительности: увеличивает нагрузку на главный поток, из-за чего страдает INP, а также может задерживать появление основного контента и ухудшать LCP (Largest Contentful Paint, загрузку основного контента). Поэтому оптимизацию производительности обычно начинают с анализа и сокращения клиентского JavaScript: удаляют неиспользуемый код, разделяют бандл на части и загружают только то, что действительно нужно пользователю сейчас. Способы — по возрастанию сложности.
Сначала измерьте
СкопированоНе оптимизируйте вслепую — посмотрите, из чего состоит бандл. webpack или @next рисуют интерактивную карту, где сразу видно самые большие пакеты.
Делите бандл (code splitting)
СкопированоРазделяйте код по роутам (каждая страница должна загружать только свой код) и по компонентам. То, что не нужно на первом экране, грузите лениво — через динамический import или React в чистом React или через next в 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 вместо import , точечные импорты из date вместо подключения целой библиотеки вроде moment, или нативные аналоги. Импорт «всего сразу» обычно убивает tree shaking.
Берегитесь barrel-файлов
СкопированоУдобный index, который реэкспортирует всё из папки (export * from '), может помешать tree shaking: импортируете одну кнопку — загружается вся UI-библиотека. В Next.js это лечит опция optimize: импорты разворачиваются в точечные автоматически.
Выносите вычисления на сервер
СкопированоСамый большой клиентский бандл часто делает работу, которой там не место, например, рендер Markdown. В Next.js App Router это уносится в серверные компоненты, и объём клиентского JavaScript уменьшается. Дальше — замените тяжёлые зависимости на лёгкие аналоги, сожмите ответ (Brotli) и поставьте бюджет размера бандла в CI, чтобы он не рос незаметно от релиза к релизу.
Лучший JavaScript — тот, который не пришлось отправлять в браузер. Прежде чем оптимизировать выполнение скрипта, спросите, нужен ли он на этой странице вообще и нельзя ли сделать то же самое разметкой, CSS или выполнить на сервере.
Другие приёмы
СкопированоПрименяйте дебаунсинг к частым событиям (input, resize, scroll): не запускайте тяжёлую логику при каждом событии. Особенно актуально для автокомплита: без дебаунса обработчик выполняется на каждое нажатие клавиши, и в результате браузеру приходится обрабатывать множество ненужных для него ответов. Для событий, которые нужно ограничить по частоте, а не отложить до паузы (скролл, resize), больше подходит троттлинг.
Отменяйте устаревшие запросы через Abort. Если пользователь быстро печатает в поле поиска, каждое изменение может отправлять новый запрос через 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,
});
// ...
});
Осторожнее с таймерами: set мешает интерактивности сильнее, чем разовый set, потому что продолжает регулярно запускать работу, пока его явно не остановят. Проверяйте, не остались ли в приложении забытые set (частый источник — счётчики, поллинг, автосохранение), и переносите тяжёлую работу из таймеров в Web Worker.
Анимируйте через CSS, а не request, там, где это возможно: браузер сможет выполнить её без участия главного потока, если анимируются только transform и opacity — свойства, которые браузер обсчитывает на этапе композиции, минуя главный поток. Если JS-анимация всё же нужна, следите, чтобы она не переводила вычисления обратно на главный поток (не анимируйте width/top/box, смотрите также приёмы для CLS).
Не рендерите большие куски HTML на клиенте через inner или его аналоги. В отличие от серверного HTML, который браузер может разбирать и отображать потоково, клиентская вставка не нарезается автоматически: парсинг и вставка большой разметки выполняются как одна длинная задача и блокируют кадр. Плюс ресурсы (картинки, скрипты) внутри такой разметки не увидит предзагрузочный сканер браузера (механизм, который заранее вычитывает разметку и начинает качать ресурсы, не дожидаясь парсера) — а значит, это уже затрагивает LCP. Максимизируйте серверный рендеринг (SSR, отрисовка HTML на сервере при каждом запросе), статическую генерацию (SSG, HTML собирается заранее на этапе сборки) или серверные компоненты React (RSC) и старайтесь, чтобы объём клиентской отрисовки оставался небольшим.
Не делайте layout thrashing: не чередуйте чтение и запись геометрии в одном обработчике. Если после изменения стиля тут же прочитать offset, браузер вынужден немедленно пересчитать 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, чтобы сократить объём работы при расчёте стилей, layout и отрисовке. Lighthouse начинает предупреждать о большом DOM уже после 800 узлов, а после 1400 считает проблему серьёзной: чем больше DOM и глубже его вложенность, тем дороже становятся расчёт стилей, layout и обновление страницы. Помогают фрагменты вместо лишних обёрток (<> в React), более плоская структура DOM без цепочек div > div > div, а также отложенная отрисовка скрытых секций (например, вкладок и аккордеонов) до момента, когда они действительно понадобятся.
Подробнее про content-visibility
content откладывает расчёт стилей, layout и отрисовку содержимого за пределами экрана и выполняет их только тогда, когда элемент приближается к вьюпорту. На длинных страницах (ленты, документация с якорями) это может заметно ускорить рендер: в одном из примеров Google время сократилось с 232 до 30 мс.
Чтобы избежать скачков макета, обязательно задавайте contain. Пока содержимое ещё не отрисовано, браузер использует это значение как предполагаемый размер элемента.
.section { content-visibility: auto; contain-intrinsic-size: auto 1000px;}
.section {
content-visibility: auto;
contain-intrinsic-size: auto 1000px;
}
Есть и второе значение — content. В отличие от display, оно скрывает содержимое, но сохраняет результаты предыдущих расчётов layout и paint, поэтому повторное отображение обычно обходится дешевле. Это полезно для скрытых вкладок, виртуального скролла и других сценариев, где содержимое часто показывается и скрывается.
Из подводных камней:
- вызов DOM-методов вроде
getилиBounding Client Rect ( ) offsetна скрытом поддереве принудительно запускает layout, сводя выигрыш на нет;Height - если скрытый контент не должен быть доступен скринридерам, добавляйте
aria.- hidden = "true"
Упрощайте CSS-селекторы: пересчёт стилей требует проверить, какие селекторы подходят каждому элементу, поэтому длинная цепочка вроде .box обычно обходится дороже, чем один класс .final (подробнее о том, как браузер сопоставляет селекторы — в статье про специфичность). На страницах с большим DOM и частыми обновлениями интерфейса это помогает уменьшить стоимость пересчёта стилей и улучшить INP.
Дробите большие скрипты: разбор и выполнение большого JavaScript-файла легко превращаются в длинную задачу, которая блокирует главный поток, даже если код-сплиттинг из раздела про размер бандла уже применён. Ориентир — около 100 КБ на файл: ES-модули (type) сами по себе грузятся и парсятся отдельными кусками, благодаря чему браузер успевает обработать пользовательский ввод между ними.
На мобильных устройствах задавайте touch для кнопок и других интерактивных элементов. Свойство отключает ожидание второго тапа для масштабирования и тем самым убирает историческую задержку около 300 мс перед обработкой нажатия. Поддерживается всеми современными браузерами.
Если вы используете React
СкопированоИспользуйте конкурентные возможности React — use и use, чтобы помечать тяжёлые обновления как несрочные и не блокировать срочные взаимодействия пользователя. Во время конкурентного рендеринга React периодически уступает главный поток, поэтому ввод и клики не приходится ждать завершения большого обновления.
В SSR-приложениях дробите гидратацию через <. Гидратация — это момент, когда React «оживляет» полученную с сервера статичную разметку, навешивая на неё обработчики событий и состояние. Чем гранулярнее границы <, тем меньше компонентов React гидратирует синхронно за раз — главный поток блокируется на меньшее время. А серверные компоненты вообще не гидратируются: для них шаг гидратации пропускается, и это лучшее, что можно сделать для INP в React.
В React 19.2 прячьте неактивные табы, модалки и содержимое за пределами экрана через < вместо условного рендера или display вручную. React не размонтирует компонент: состояние, DOM-узлы и позиция скролла сохраняются, но все use внутри останавливаются, а сам узел выпадает из расчётов layout и paint у браузера — так что скрытый контент почти ничего не стоит браузеру, хотя формально остаётся в DOM. Контент можно заранее подготовить в mode, поэтому переключение на видимый режим происходит без фриза интерфейса.
Углублённая оптимизация
СкопированоРазбивайте длинные задачи и уступайте главный поток. Главный приём — периодически уступать главный поток браузеру, чтобы он успевал обрабатывать нажатия. Современный способ — scheduler:
async function saveSettings() { // То, что важно показать сразу: validateForm(); showSpinner(); updateUI(); // Уступаем главный поток — браузер успеет ответить на действия: await scheduler.yield(); // То, что пользователь не видит, — отдельной задачей: saveToLocalStorage(); sendAnalytics();}
async function saveSettings() {
// То, что важно показать сразу:
validateForm();
showSpinner();
updateUI();
// Уступаем главный поток — браузер успеет ответить на действия:
await scheduler.yield();
// То, что пользователь не видит, — отдельной задачей:
saveToLocalStorage();
sendAnalytics();
}
У scheduler есть приятная особенность: продолжение после await встаёт в очередь с приоритетом — оно обычно выполняется раньше обычных задач из очереди благодаря механизму приоритетов Scheduler API (в отличие от set, который помещает продолжение в конец очереди; почитайте подробнее про микро- и макрозадачи, если хочется понять механику целиком). Он появился в Chrome 129 (сентябрь 2024), поддерживается Chromium-браузерами и Firefox начиная с версии 142. Но Safari пока не поддерживает, поэтому нужен фолбэк на set:
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 для проверки «не ждёт ли ввод» — разработчики Chrome и гайды web.dev не рекомендуют на неё полагаться: метод может вернуть false, хотя пользователь уже нажал. Вместо этого используйте scheduler (с фолбэком на set, см. yield выше).
Сначала показать, потом досчитать: обновите видимый интерфейс, а тяжёлую работу перенесите в следующую задачу через await yield.
Выносите тяжёлые вычисления в Web Worker: он работает в отдельном потоке и не блокирует интерфейс. Общаться с воркером напрямую через post/onmessage неудобно, поэтому обычно берут библиотеку Comlink — она заворачивает воркер в обычный объект с промисами, которые выглядят как обычные асинхронные методы, возвращающие Promise. Из ограничений — у воркера нет доступа к DOM, поэтому в нём место только чистым вычислениям (парсинг, сортировка больших массивов, обработка данных), а не UI-логике; и учтите накладные расходы на сериализацию сообщений: для мелких данных не имеет значения, а вот большие объекты (мегабайты) лучше прогонять через Array.
Сторонние скрипты и тег-менеджеры
СкопированоПо данным DebugBear, задержка отрисовки (presentation delay) составляет около 42% от INP, и один из крупнейших вкладов в неё вносят именно сторонние скрипты: теги-менеджеры с кучей тегов, A/B-тесты на клиентской части, чаты-виджеты, тепловые карты и аналитика. Они крутятся на главном потоке и блокируют отклик, особенно на мобильных.
Обычный Web Worker им не подходит: у воркеров нет доступа к DOM, а тег-менеджеры вроде Google Tag Manager (GTM) активно с ним работают. Решение — Partytown: библиотека запускает сторонний скрипт в воркере и прозрачно проксирует обращения к DOM обратно в главный поток. В Next.js подключается через next со стратегией worker, либо напрямую как библиотека — выбор момента загрузки стороннего кода задаётся значениями before, after (по умолчанию), lazy или worker. Грузите такие скрипты отложенно через lazy и удаляйте то, что не приносит пользы. В отдельных проектах перенос GTM в Partytown позволял снизить TBT (Total Blocking Time, суммарное время блокировки главного потока) на 92%.
Внутри тег-менеджера тоже есть на чём экономить. По убыванию производительности: пиксели (простой GET/POST-запрос без выполнения JS после) → кастомные шаблоны (готовые заготовки тегов внутри тег-менеджера) → Custom HTML-теги (самые дорогие, вставляют разметку и форсируют layout). Не тащите через тег-менеджер то, что должно быть на экране сразу (баннер cookie, главную картинку первого экрана): тег-менеджер задерживает их доставку. Держите контейнер компактным (контейнер — весь набор тегов, который тег-менеджер подгружает на страницу одним файлом; у Google Tag Manager жёсткий лимит 300 КБ, обычный размер — около 50 КБ), убирайте неиспользуемые теги и переменные, не дублируйте один и тот же тег в разметке и в менеджере одновременно.
Для встроенных виджетов (карты, видео, соцсети) работает паттерн facade: сначала статичный превью-скриншот вместо живого эмбеда, preconnect при наведении и полноценная загрузка только по клику. lite таким способом заметно ускоряется по сравнению с обычным <iframe> YouTube. Плюс loading на <iframe>, если полноценный facade избыточен, и обязательные width/height, иначе догрузка виджета сдвигает макет так же, как незаданные размеры картинки.