INP: отзывчивость интерфейса

Разбираемся, почему интерфейс «залипает» после клика, и учимся возвращать главному потоку возможность дышать.

INP (Interaction to Next Paint) — метрика Core Web Vitals, измеряющая время от действия пользователя (клик, тап, нажатие клавиши) до следующего кадра, в котором виден результат. С марта 2024 года она заменила FID.

Что именно меряет INP

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

Задержка складывается из трёх частей:

ФазаЧто происходитКто виноват
Input delayСобытие ждёт, пока главный поток освободитсячужая работа: аналитика, гидратация, таймеры
Processing timeРаботают ваши обработчики событийваш код в onClick
Presentation delayБраузер считает стили, layout, рисует кадртяжёлый DOM, большие ререндеры

Пороги: до 200 мс — хорошо, 200–500 мс — требует улучшения, больше 500 мс — плохо. Оценивается 75-й процентиль реальных пользователей, а не ваш ноутбук.

Корень зла — долгие задачи

Long task (долгая задача) — непрерывный кусок работы, занимающий главный поток дольше 50 мс. Всё это время браузер не может ни обработать клик, ни нарисовать кадр.

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

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.warn('Долгая задача:', Math.round(entry.duration), 'мс', entry.name);
  }
}).observe({ type: 'longtask', buffered: true });

А реальный INP живых пользователей собирается официальной библиотекой — на синтетике его достоверно не измеришь, потому что метрика зависит от того, куда именно люди кликают:

npm install web-vitals
import { onINP } from 'web-vitals';

onINP((metric) => {
  // metric.value — миллисекунды, metric.attribution подскажет виновный элемент
  navigator.sendBeacon('/rum', JSON.stringify({
    name: metric.name,
    value: metric.value,
    target: metric.attribution?.interactionTarget,
  }));
});

Приём 1: разбить работу на куски

Если задача большая, её надо порезать и между кусками отдавать управление браузеру. Посчитаем, каким должен быть кусок, чтобы уложиться в бюджет 50 мс:

// Один элемент обрабатывается ~0.4 мс. Бюджет на непрерывную работу — 50 мс.
const COST_MS = 0.4;
const BUDGET_MS = 50;

const chunkSize = Math.floor(BUDGET_MS / COST_MS);
const items = Array.from({ length: 1000 }, (_, i) => i);

function toChunks(list, size) {
  const chunks = [];
  for (let i = 0; i < list.length; i += size) {
    chunks.push(list.slice(i, i + size));
  }
  return chunks;
}

const chunks = toChunks(items, chunkSize);

console.log('Размер куска:', chunkSize);
console.log('Кусков всего:', chunks.length);
console.log('Одной задачей:', (items.length * COST_MS).toFixed(0), 'мс сплошной блокировки');
console.log('Одним куском: ', (chunkSize * COST_MS).toFixed(0), 'мс, между кусками поток свободен');

Результат:

Размер куска: 125
Кусков всего: 8
Одной задачей: 400 мс сплошной блокировки
Одним куском:  50 мс, между кусками поток свободен

Суммарно работы столько же — 400 мс. Но в первом случае это одна глухая заморозка, а во втором восемь коротких задач, между которыми браузер успевает обработать клик и нарисовать кадр. Для пользователя разница между «завис» и «работает».

Отдать управление главному потоку в реальном коде можно так:

async function yieldToMain() {
  // современный способ: сохраняет очередь задач и приоритет
  if ('scheduler' in window && 'yield' in window.scheduler) {
    return window.scheduler.yield();
  }
  // universal fallback
  return new Promise((resolve) => setTimeout(resolve, 0));
}

async function processAll(items, handle) {
  let deadline = performance.now() + 50;

  for (const item of items) {
    handle(item);

    if (performance.now() >= deadline) {
      await yieldToMain();                    // даём браузеру вдохнуть
      deadline = performance.now() + 50;
    }
  }
}

Разберём. Мы не режем список по счётчику («каждые 100 элементов»), а следим за временем: элементы бывают разной тяжести, а бюджет кадра — величина постоянная. Как только с начала куска прошло 50 мс, мы дожидаемся yieldToMain() — и браузер получает шанс обработать накопившиеся события. После возврата отсчитываем бюджет заново.

Некритичное — в простой

Есть работа, которую вообще не нужно делать сейчас: отправка аналитики, прогрев кеша, подсчёт статистики. Для неё существует requestIdleCallback — колбэк выполнится, когда браузер простаивает.

requestIdleCallback((deadline) => {
  while (deadline.timeRemaining() > 0 && queue.length > 0) {
    sendAnalyticsEvent(queue.shift());
  }
}, { timeout: 2000 });   // но не позже чем через 2 секунды

timeout здесь обязателен: без него на вечно занятой странице колбэк может не выполниться никогда.

Приём 2: вынести вычисления в Web Worker

Разбиение на куски растягивает работу во времени, но не убирает её из главного потока. Если работа действительно тяжёлая — сортировка 50 тысяч строк, парсинг большого JSON, обработка изображения, — её надо унести в Web Worker: отдельный поток со своим движком JS, который физически не может заблокировать интерфейс.

// worker.js — отдельный поток, DOM здесь недоступен
self.onmessage = (e) => {
  const { rows, query } = e.data;

  const result = rows
    .filter((r) => r.name.toLowerCase().includes(query))
    .sort((a, b) => b.score - a.score)
    .slice(0, 100);

  self.postMessage(result);
};
// main.js — главный поток остаётся свободным
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });

searchInput.addEventListener('input', (e) => {
  worker.postMessage({ rows, query: e.target.value.toLowerCase() });
});

worker.onmessage = (e) => {
  renderRows(e.data);   // рисуем уже готовые 100 строк
};

Важный нюанс: данные между потоками копируются (structured clone). Гонять туда-сюда мегабайты на каждое нажатие клавиши — значит заменить одну проблему другой. Передавайте в воркер данные один раз, держите их там, а обратно возвращайте только результат. Для бинарных данных есть переносимые объекты (ArrayBuffer), которые передаются без копирования.

Приём 3: виртуализация длинных списков

Отдельная и очень частая причина плохого INP — гигантский DOM. Таблица на 10 000 строк — это десятки тысяч узлов, и каждый пересчёт стилей и layout идёт по ним всем. Клик по чекбоксу в такой таблице честно отрабатывает за 2 мс, а кадр рисуется 400 мс: виноват не обработчик, а presentation delay.

Лечение — виртуализация (windowing): в DOM держим только видимые строки плюс небольшой запас сверху и снизу, а высоту прокрутки эмулируем распорками.

import { FixedSizeList } from 'react-window';

function Row({ index, style, data }) {
  return (
    <div style={style}>
      {data[index].name}
    </div>
  );
}

export function BigTable({ rows }) {
  return (
    <FixedSizeList
      height={600}          // высота окна прокрутки
      itemCount={rows.length}  // 10 000 строк логически
      itemSize={40}         // но в DOM живёт ~20 из них
      width="100%"
      itemData={rows}
    >
      {Row}
    </FixedSizeList>
  );
}

Здесь itemCount может быть хоть миллион: в разметке одновременно существует столько строк, сколько помещается в 600 пикселей высоты. Layout снова считается за миллисекунды.

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

Браузер крутит цикл событий: берёт задачу из очереди, выполняет её до конца, затем при необходимости рисует кадр, затем берёт следующую. Ключевое слово — до конца: вытеснить выполняющуюся задачу нельзя. Клик пользователя становится новой задачей в очереди, и она дождётся своей очереди. Поэтому единственный способ сделать интерфейс отзывчивым — не создавать длинных задач: либо резать их на короткие и явно отдавать управление, либо уносить в другой поток. Микрозадачи (Promise.then, await уже выполненного промиса) здесь, кстати, не спасают: они выполняются в конце той же задачи, не давая браузеру нарисовать кадр — потому в yieldToMain() и стоит setTimeout, а не голый промис.

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

  • Полагаться на лабораторные замеры. INP — полевая метрика: Lighthouse покажет TBT (общее время блокировки), но настоящий INP приходит только из RUM-данных реальных пользователей.
  • Оптимизировать только загрузку. INP измеряется всю сессию. Страница может открыться мгновенно и залипать на каждом клике через минуту работы.
  • Debounce вместо решения. Отложенный вызов тяжёлой функции остаётся тяжёлым вызовом: он просто заморозит интерфейс на 300 мс позже.
  • Считать await «отдачей управления». Await промиса, который уже выполнен, — микрозадача: кадр так и не будет нарисован.
  • Слать в воркер весь массив на каждое нажатие клавиши. Стоимость копирования съест выигрыш от параллелизма.
  • Делать всю работу до отрисовки. Правильный порядок: сначала показать пользователю отклик (нажатую кнопку, спиннер, изменившийся чекбокс), а тяжёлое досчитать после следующего кадра.

Итоги

  • INP меряет путь от действия до нарисованного кадра; цель — не больше 200 мс у 75 % пользователей.
  • Враг — долгие задачи (дольше 50 мс): главный поток однопоточен и вытеснить задачу нельзя.
  • Режьте работу по времени и явно отдавайте управление через scheduler.yield() или setTimeout(…, 0); некритичное отправляйте в requestIdleCallback.
  • Действительно тяжёлые вычисления уносите в Web Worker и передавайте между потоками только результат.
  • Гигантский DOM бьёт по INP не хуже плохого обработчика — длинные списки виртуализируйте.
  • Сначала отклик, потом вычисления: пользователю важно увидеть реакцию в ближайшем кадре.
Проверьте себя
1. Какая задача считается long task и мешает INP?
AЛюбая асинхронная операция, которая ждёт ответ сервера дольше 200 мс
BНепрерывная работа, занимающая главный поток дольше 50 мс
CЛюбая функция длиннее 50 строк кода
DЗагрузка чанка JavaScript размером больше 50 КБ
2. Клик по фильтру пересортировывает и перерисовывает таблицу на 50 000 строк, интерфейс залипает на полсекунды. Что даст наибольший эффект?
AОбернуть обработчик в debounce на 300 мс
BДобавить таблице CSS-свойство will-change: transform
CВынести сортировку и фильтрацию в Web Worker, а сам список виртуализировать
DЗаменить setTimeout на requestAnimationFrame внутри обработчика