Перейти к содержимому

13 минут чтения

INP по фазам: на что уходят миллисекунды клика

Как улучшить INP: замерил 14 сценариев в Chrome и разложил задержку клика на input delay, processing и presentation delay. Почему вставка 3000 строк стоит 580 мс уже после того, как код закончил работу, и что реально лечит каждую фазу.

Чтобы улучшить INP, сначала нужно узнать, в какой из трёх фаз теряется время: пока браузер не может начать обработку клика (input delay), пока работают обработчики (processing duration) или пока браузер считает и рисует следующий кадр (presentation delay). Лекарства у фаз разные, и лекарство не от той фазы не помогает. Я собрал стенд из 14 сценариев и замерил каждую фазу в Chrome 153 с замедлением процессора в 4 раза. Самый показательный результат: вставка 3000 строк в список стоила 632 мс INP, из которых обработчик клика работал 52 мс, а остальное ушло на стили и раскладку уже после того, как код закончил работу. Ускорять такой обработчик бесполезно, помог content-visibility: auto: INP упал до 112 мс.

Как считается INP

INP (Interaction to Next Paint) – метрика Core Web Vitals, которая показывает, как быстро страница отвечает на действия пользователя. Браузер берёт клики мышью, касания экрана и нажатия клавиш за всё время жизни страницы. Наведение, прокрутка и масштабирование не учитываются. Задержка одного взаимодействия – время от действия пользователя до следующего кадра, в котором браузер может показать результат. Окончание сетевых запросов и асинхронных обновлений в неё не входит, только первая возможность показать хоть какой-то отклик.

Из всех взаимодействий за визит берётся самое медленное, но на каждые 50 взаимодействий одно самое долгое отбрасывается, чтобы случайный сбой на странице с сотнями кликов не определял результат. Затем по всем визитам берётся 75-й перцентиль отдельно для мобильных и десктопа. Пороги:

INP на 75-м перцентиле

Оценка

до 200 мс

хорошо

200–500 мс

нужно улучшить

больше 500 мс

плохо

Две технические детали, которые объясняют странные цифры в отчётах:

  • Длительность округляется до 8 мс. Так задано в спецификации Event Timing, на которой построен INP. Поэтому в моих таблицах значения кратны 8, а лёгкий клик показывает 8 или 16 мс.
  • API по умолчанию не сообщает о событиях короче 104 мс. Порог меняется параметром durationThreshold, но не ниже 16 мс. Библиотека web-vitals это учитывает, самописный PerformanceObserver – не всегда.

По данным Chrome, 90% времени на странице пользователь проводит уже после её загрузки. Поэтому INP измеряет отзывчивость за весь визит, а его предшественник FID смотрел только на задержку перед первым взаимодействием.

Из чего складывается задержка клика: три фазы INP

Документация web.dev и инструменты Chrome делят задержку одного взаимодействия на три подчасти, их сумма равна задержке целиком.

  1. Input delay – от действия пользователя до момента, когда начинают выполняться обработчики событий. Обработчики ещё не работают: главный поток занят чем-то другим.
  2. Processing duration – время работы всех обработчиков событий этого взаимодействия. Один клик порождает несколько событий: pointerdown, pointerup, click, и в фазу попадают обработчики всех событий, обработанных в одном кадре.
  3. Presentation delay – от завершения обработчиков до показа следующего кадра. Сюда входят колбэки requestAnimationFrame, ResizeObserver и IntersectionObserver, пересчёт стилей, раскладка (layout), отрисовка и работа композитора.

Главный поток у страницы не один: у каждого iframe свой. INP считается для страницы целиком, включая медленные клики внутри фреймов, но JavaScript-API не видит, что происходит в iframe. Поэтому данные CrUX и собственного мониторинга на страницах с виджетами во фреймах могут расходиться.

Стенд: как я мерил фазы INP

Условия эксперимента
  • Google Chrome for Testing 153.0.8010.12 в режиме --headless=new, управление через playwright-core 1.64.1.
  • Сервер с двумя ядрами Intel Xeon 2,1 ГГц, замедление процессора в 4 раза через DevTools Protocol (Emulation.setCPUThrottlingRate), окно 412 × 915.
  • Фазы считает библиотека web-vitals 6.2.3 (сборка с атрибуцией, onINP с reportAllChanges), то есть так же, как их увидит система мониторинга на реальных пользователях.
  • Клик – настоящие события мыши через DevTools Protocol, без ожидания отрисовки перед кликом.
  • Каждый сценарий – 15 загрузок страницы с одним кликом. В таблице медианы, отдельно для INP и для каждой фазы, поэтому сумма фаз может не совпадать с INP на несколько миллисекунд.
  • Синтетическая «тяжёлая работа» – цикл, который занимает поток на заданное время по часам, поэтому её длительность не зависит от замедления. Реальная работа фреймворков и браузера замедлением затронута.
  • React 19.3.1, Vue 3.5.44, сборка esbuild 0.28.2 в режиме production.

Это лабораторный замер на сервере, а не на телефоне. Абсолютные цифры на реальном слабом устройстве будут другими, поэтому смотреть стоит на то, в какую фазу уходит время и во сколько раз меняется результат от исправления.

Сценарий

INP

Input delay

Processing

Presentation

Лёгкий обработчик, поток свободен

16

2

6

6

Лёгкий обработчик, в потоке чужая задача 150 мс

144

130

8

6

То же, чужая задача порезана на куски по 10 мс

24

9

8

5

Обработчик работает 150 мс

168

2

157

9

Обновить UI, остальное после кадра (requestAnimationFrame + setTimeout)

16

1

9

7

Обновить UI, остальное после scheduler.yield()

16

2

9

7

1000 элементов: запись и чтение размеров вперемешку

3632

2

3600

37

То же: сначала все записи, потом все чтения

72

2

39

28

Вставка 3000 строк через innerHTML

632

1

52

579

То же, строки в секциях с content-visibility: auto

112

1

73

37

React: обновление списка из 3000 строк

536

2

111

423

React: список в startTransition

112

2

82

27

React: startTransition и список в memo

56

2

28

29

Vue: обновление списка из 3000 строк

664

4

128

562

Все значения – мои замеры, миллисекунды, медиана 15 прогонов.

Куда уходит время клика: фазы INP по сценариямМедианы фаз INP в Chrome 153 с замедлением CPU в 4 раза. Сценарий с чередованием записи и чтения размеров (3632 мс) не показан, чтобы не ломать масштаб.
Куда уходит время клика: фазы INP по сценариям
Input delayProcessingPresentation
Чужая задача 150 мс130 мс8 мс6 мс
Обработчик 150 мс2 мс157 мс9 мс
Вставка 3000 строк1 мс52 мс579 мс
То же + content-visibility1 мс73 мс37 мс
React, синхронно2 мс111 мс423 мс
React, startTransition + memo2 мс28 мс29 мс
Vue4 мс128 мс562 мс

Источник: собственный замер, web-vitals 6.2.3

Дальше по каждой фазе: что её раздувает, как это видно и что помогает.

Input delay: кнопка не виновата

Во втором сценарии обработчик клика тривиальный, а INP равен 144 мс: 130 из них клик ждал, пока закончится чужая задача. Если смотреть только на код обработчика, причину не найти. Инструмент Chrome DevTools «INP breakdown» прямо предупреждает: при большом input delay взаимодействие может быть не причиной, а жертвой.

Что обычно занимает поток в момент клика, по документации web.dev:

  • Выполнение скриптов при загрузке. Браузер разбирает, компилирует и выполняет каждый загруженный скрипт, и большой бандл превращается в длинную задачу. Поэтому лишний код из неудачного tree shaking бьёт не только по загрузке, но и по INP первых кликов. Туда же относится гидрация SSR-страницы, подробнее о ней в статье про hydration mismatch.
  • Таймеры, особенно повторяющиеся setInterval, в том числе у сторонних скриптов аналитики и виджетов.
  • Колбэки fetch, если их много и они приходят одновременно.
  • Предыдущее взаимодействие, которое ещё не отрисовало свой кадр: быстрый набор текста в поле с подсказками – типичный пример.
  • Анимации на JavaScript с большим числом requestAnimationFrame.

Длинной задачей (long task) называется задача дольше 50 мс. Лечится input delay не ускорением обработчика, а дроблением чужих задач. В третьем сценарии та же 150-миллисекундная работа порезана на куски по 10 мс с await scheduler.yield() между ними, и input delay упал со 130 до 9 мс, хотя работы стало не меньше. Клик дождался конца текущего куска и выполнился вне очереди.

Для сторонних скриптов, которые вы не контролируете, остаются более грубые меры: загружать их позже, после первого взаимодействия или по requestIdleCallback, и убирать те, которые не окупаются. Для своих частых обработчиков ввода web.dev советует дебаунс и отмену устаревших запросов через AbortController.

Processing duration: обработчик делает лишнее до кадра

Обработчик, который работает 150 мс, даёт INP 168 мс, и почти всё время – в processing. Это самый понятный случай, и в поле он самый частый среди медленных взаимодействий: в данных RUMvision за октябрь–ноябрь 2023 года (6,4 млн просмотров) у половины мобильных взаимодействий с INP больше 200 мс processing был не меньше 120 мс.

Сначала отклик, потом остальное

INP измеряет время до следующего кадра, а не до конца всей работы. Поэтому до кадра нужно сделать только то, что пользователь должен увидеть сразу: переключить кнопку, показать индикатор, вставить символ. Подсчёт, сохранение, аналитику и обновление второстепенных блоков можно отложить. web.dev приводит пример редактора текста: сразу обновляется только поле ввода, а подсчёт слов, проверка орфографии и сохранение откладываются.

В моём стенде оба способа отложить работу дали одинаковый результат: INP 16 мс вместо 168, хотя 150 мс работы никуда не делись.

  • requestAnimationFrame + setTimeout. Колбэк requestAnimationFrame выполняется перед отрисовкой, а setTimeout внутри него ставит работу в новую задачу уже после кадра. Приём выглядит странно, но работает во всех браузерах.
  • await scheduler.yield(). Уступает поток браузеру, а продолжение функции встаёт в очередь с повышенным приоритетом: оно выполнится раньше других задач того же приоритета, в отличие от setTimeout, который ставит работу в конец очереди. В моём замере Chrome 153 успевал показать кадр до продолжения. Документация отрисовку между уступкой и продолжением явно не обещает, поэтому проверяйте на своих сценариях.

Минимальный вариант с scheduler.yield():

JavaScript
button.addEventListener('click', async () => {
  showSelected()          // то, что пользователь должен увидеть сразу
  await scheduler.yield()
  recalcTotals()          // всё остальное
  sendAnalytics()
})

scheduler.yield() есть в Chrome и Edge с версии 129 и в Firefox с версии 142, в Safari его нет. Для Safari нужен запасной путь через setTimeout или полифил scheduler-polyfill. У setTimeout есть ещё одна особенность: после пяти уровней вложенности браузер добавляет минимальную задержку 5 мс к каждому вызову. Метод navigator.scheduling.isInputPending(), который раньше советовали для проверки ожидающего ввода, web.dev больше не рекомендует: уступать поток лучше всегда.

Принудительная раскладка

Самый дорогой сценарий стенда: 1000 элементов, у каждого меняется ширина, и сразу после изменения читается offsetWidth. INP – 3,6 секунды. Чтение размера после записи стиля заставляет браузер пересчитать раскладку немедленно, внутри обработчика, и так 1000 раз подряд. Это называется layout thrashing, или принудительная синхронная раскладка. Если сначала выполнить все записи, а потом все чтения, раскладка считается один раз, и INP падает до 72 мс. Обратите внимание, что эта работа – вычисление раскладки браузером – попадает в processing, а не в presentation: она происходит внутри обработчика. В Performance panel такие места отмечены красным треугольником на блоках Recalculate Style и Layout.

Presentation delay: JavaScript закончил, а кадра нет

Вставка 3000 строк через innerHTML – 632 мс INP, из них обработчик работал 52 мс, а 579 мс браузер считал стили, раскладку и рисовал уже после обработчика. Ускорять код здесь бесполезно: его почти нет.

Presentation delay растёт вместе с объёмом DOM, который нужно пересчитать после изменения. Lighthouse предупреждает о размере DOM от 800 узлов и считает чрезмерным больше 1400. Что помогает:

  • Меньше DOM за раз. Не рендерить скрытое заранее и добавлять его при открытии, а длинные списки показывать порциями или виртуализировать.
  • content-visibility: auto. Браузер пропускает раскладку и отрисовку элементов за пределами экрана, пока они не приблизятся к видимой области. В стенде я разбил строки на секции по 100 и задал им content-visibility: auto с contain-intrinsic-size: INP упал с 632 до 112 мс, presentation delay – с 579 до 37 мс. Processing при этом вырос с 52 до 73 мс, в моём коде – из-за нарезки строки на секции. У свойства есть побочные эффекты со скроллбаром, поиском по странице и якорями, им будет посвящена отдельная статья.
  • Простые селекторы и стили. Сложные селекторы удорожают пересчёт стилей на каждое изменение.

Сервер присылает HTML потоком, и браузер разбирает его кусками, уступая поток между ними. HTML, созданный на клиенте, такой нарезки не получает: всё, что вставлено в одной задаче, разбирается и раскладывается целиком.

React и Vue: где оказывается рендер фреймворка

Распространённое объяснение – «React и Vue обновляют DOM асинхронно, поэтому их рендер не мешает клику». Замер показывает другое: рендер фреймворка в ответ на клик попадает в processing duration, то есть до кадра.

Vue по документации откладывает обновление DOM до «следующего тика», чтобы каждый компонент обновился один раз, сколько бы состояний ни поменялось. React для дискретных событий вроде клика, по словам одного из его разработчиков в обсуждении React 18, выполняет обновление синхронно в микрозадаче. Отложенное – не значит после кадра: в стенде обновление 3000 строк дало processing 111 мс в React и 128 мс во Vue, а потом ещё 423 и 562 мс presentation delay на раскладку и отрисовку изменившихся строк.

Что дало эффект в React:

  • startTransition помечает обновление как несрочное: React рендерит его в фоне, может прервать ради нового ввода, и клик получает кадр раньше. В стенде кнопка обновлялась сразу, а список – в transition, INP упал с 536 до 112 мс. Ограничение из документации: transition нельзя использовать для обновлений, которые управляют текстовым полем.
  • memo на тяжёлом поддереве. Без него срочное обновление кнопки всё равно перерисовывает весь список из 3000 строк в виртуальном DOM, хотя изменений там нет, – это 82 мс processing. С memo осталось 28 мс, INP – 56 мс.

Во Vue прямого аналога startTransition нет. Документация Vue по производительности предлагает уменьшать объём обновлений: стабильные пропсы, чтобы дочерние компоненты не перерисовывались зря, v-memo и v-once для больших поддеревьев, виртуализацию длинных списков и shallowRef для больших неизменяемых структур данных. Тяжёлое несрочное обновление можно отложить так же, как в чистом JavaScript: изменить сначала то, что нужно для отклика, потом await scheduler.yield() и остальное.

Как найти виноватую фазу на своём сайте

  1. Полевые данные. PageSpeed Insights показывает INP из CrUX – набора данных Chrome о реальных пользователях, но не говорит, какой клик медленный и почему. Для этого нужен свой мониторинг: библиотека web-vitals в сборке с атрибуцией отдаёт селектор элемента, фазы inputDelay, processingDuration, presentationDelay и состояние загрузки страницы в момент клика.
  2. Скрипт-виновник. В Chrome и Edge с версии 123 есть Long Animation Frames API (LoAF). Он сообщает о кадрах, задержанных больше чем на 50 мс, и о скриптах внутри них: URL файла, имя функции, позиция в коде, сколько из времени ушло на принудительную раскладку. web-vitals прикладывает пересекающиеся с INP кадры в longAnimationFrameEntries, а самый долгий скрипт – в longestScript вместе с фазой, в которой он работал. Это самый быстрый способ отличить свой код от стороннего.
  3. Лаборатория. Запишите взаимодействие в Performance panel Chrome DevTools с замедлением CPU и откройте инсайт «INP breakdown». Кликайте и во время загрузки страницы: в это время поток занят сильнее всего, и input delay проявляется именно тогда. В данных RUMvision 42% мобильных взаимодействий, ставших INP, пришлись на первые 10 секунд визита.

Минимальный код для сбора фаз:

JavaScript
import { onINP } from 'web-vitals/attribution'

onINP(({ value, attribution: a }) => {
  sendToAnalytics({
    inp: value,
    target: a.interactionTarget,
    inputDelay: a.inputDelay,
    processing: a.processingDuration,
    presentation: a.presentationDelay,
    script: a.longestScript?.entry.sourceURL,
  })
})

Одна оговорка про фазы. Браузер группирует события по кадрам, и если в одном кадре обработалось несколько взаимодействий, обработчик одного может оказаться в input delay другого. Барри Поллард из команды Chrome в обсуждении W3C советует при большом presentation delay воспроизводить в лаборатории именно это взаимодействие. При большом input delay, как уже говорилось, искать нужно то, что ещё занимало поток.

Какой INP у сайтов на самом деле

По Web Almanac 2025 (данные CrUX за июль 2025) хороший INP у 77% сайтов на мобильных и у 97% на десктопе. На мобильных разрыв заметнее у крупных сайтов: среди 1000 самых посещаемых хороший INP только у 63%. На внутренних страницах мобильный показатель хуже, чем на главных: 69% против 80%. Total Blocking Time – лабораторная метрика суммарной блокировки потока при загрузке – на мобильных в медиане вырос за год с 1209 до 1916 мс.

Распределение по фазам в открытом доступе есть, но старое: в выборке RUMvision за 2023 год медианы на мобильных – 25 мс input delay, 39 мс processing и 53 мс presentation delay. В типичном клике больше всего весит отрисовка, а в медленных – обработчики. Более свежих открытых данных о фазах с описанной методикой мне найти не удалось.

Коротко

  • INP – это время до следующего кадра, а не до конца работы. Всё, что не нужно для мгновенного отклика, откладывайте за кадр: requestAnimationFrame + setTimeout или await scheduler.yield() с запасным путём для Safari.
  • Сначала выясните фазу: web-vitals с атрибуцией и LoAF в поле, инсайт «INP breakdown» в DevTools в лаборатории. Без этого легко неделю ускорять обработчик, который ни при чём.
  • Большой input delay лечится дроблением чужих длинных задач, меньшим объёмом JavaScript при загрузке и поздней загрузкой сторонних скриптов, а не правкой обработчика.
  • Большой presentation delay – это объём DOM и стилей после изменения. Помогают content-visibility: auto, порционный рендер и виртуализация: в стенде 632 мс превратились в 112.
  • Рендер React и Vue после клика выполняется до кадра. В React тяжёлые обновления выносите в startTransition и закрывайте тяжёлые поддеревья memo, во Vue сокращайте объём обновления.
  • Никогда не читайте размеры элементов сразу после изменения стилей в цикле: 1000 таких пар стоили 3,6 секунды.

Источники

  1. 01Interaction to Next Paint (INP)web.dev · 2025
  2. 02Optimize Interaction to Next Paintweb.dev · 2025
  3. 03Event Timing APIW3C
  4. 04Optimize long tasksweb.dev · 2024
  5. 05Use scheduler.yield() to break up long tasksChrome for Developers · 2025
  6. 06Optimize input delayweb.dev
  7. 07How large DOM sizes affect interactivityweb.dev
  8. 08Long Animation Frames APIChrome for Developers
  9. 09INP breakdownChrome for Developers · 2025
  10. 10web-vitalsGoogleChromeверсия 6.2.3
  11. 11startTransitionReact
  12. 12Automatic batching for fewer renders in React 18React Working Group · 2021
  13. 13Reactivity FundamentalsVue.js
  14. 14PerformanceVue.js
  15. 15Performance – Web Almanac 2025HTTP Archive · 2025
  16. 16INP performance analysis: RUMvision dataRick Viscomi · Web Performance Calendar · 2023