← All posts

What INP measures and how to fix slow interactions


Interaction to Next Paint (INP) is the time from a click, tap or key press to the next frame the browser paints, reported for close to the slowest interaction in a page visit. A page passes with an INP of 200 milliseconds or less at the 75th percentile of real page loads and counts as poor above 500 milliseconds, so the work starts in field data: find the interaction that is slow for real users, reproduce it in the lab, fix the phase that dominates, then confirm the change in the field.

INP replaced First Input Delay (FID) as the responsiveness Core Web Vital in March 2024. FID only timed the wait before the first interaction's handler could start, ignoring how long the handler and the paint took and every interaction after the first. A filter button that froze the page for a second on every click could still pass, as long as the first tap of the visit was picked up quickly. INP closes both gaps by watching every qualifying interaction and timing each one all the way to the paint.

Which interactions count toward the number

Google's INP definition on web.dev is narrow about scope. Only mouse clicks, taps on a touchscreen and key presses are observed. Hovering, scrolling and zooming are not. For each page visit the browser keeps the longest interaction it saw, except that on pages with many interactions one highest value is ignored for every 50 interactions, so a single freak outlier in a long session does not decide the score. The 75th percentile of those per-visit values, split by mobile and desktop, is the INP that reports show.

Two consequences shape the debugging. The value belongs to one specific interaction on one specific element, so it can be traced. And a page with a fast first impression can still fail if one later interaction, such as opening a mega menu or applying a filter, is slow.

Anatomy of one slow interaction

Every interaction splits into three consecutive phases. The phase that takes the most time tells you where to look.

click / tap / key       handlers start         handlers finish        frame painted
      |                       |                       |                      |
      |<--- input delay ----->|<-- processing time -->|<-- presentation ---->|
      |  main thread busy     |  event callbacks run  |  style, layout and   |
      |  with other tasks     |  (yours and others')  |  paint of the frame  |
      |<------------------- INP value for this interaction ----------------->|

Each phase has its own usual suspects and its own first move:

Dominant phaseTypical causesFirst fix to try
Input delayScript evaluation at startup, hydration, third-party tags, timers already runningBreak up or defer the competing work; identify it with script attribution
Processing timeHandlers that do everything at once: state updates, big loops, several analytics callsUpdate the UI first, then move the rest past the next paint
Presentation delayLarge DOM, expensive style recalculation, forced layouts, rendering offscreen contentTouch less DOM, batch reads before writes, skip offscreen rendering

When the interaction happened matters as much as which phase dominated. The web.dev guide to finding slow interactions in the field notes that the main thread is often busiest while the page is loading. A slow interaction during startup with a long input delay is probably waiting behind script evaluation or hydration, and the fix belongs in what the page runs on load, not in the handler. The same interaction on a settled page, with a long processing time, points at the handler itself. Treat this split as a hypothesis to confirm with attribution data, not as a rule.

Finding the interaction in field data

Field data comes in two layers that answer different questions.

CrUX, the Chrome dataset behind PageSpeed Insights and the Core Web Vitals report in Search Console, tells you whether a URL, a group of similar URLs or the whole origin fails INP, and on which device type. It cannot tell you which element or which script caused the failure. The grouping is still useful: Search Console groups URLs with a similar experience, which often means one template, so one diagnosis can cover thousands of pages.

The element and the script come from your own real-user monitoring (RUM). The open source web-vitals library has an attribution build that reports, for the interaction behind each visit's INP, the target element, the interaction type, the three phase durations and the document's loading state at the time. In Chromium browsers it also attaches Long Animation Frame data, including a summary of the longest script that overlapped the interaction. A minimal setup that sends all of it to your own endpoint:

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

onINP(({value, rating, attribution: a}) => {
  navigator.sendBeacon('/rum', JSON.stringify({
    metric: 'INP',
    value: Math.round(value),
    rating,                                   // good, needs-improvement, poor
    template: document.body.dataset.template || location.pathname,
    target: a.interactionTarget,              // CSS selector of the element
    type: a.interactionType,                  // pointer or keyboard
    loadState: a.loadState,                   // loading ... complete
    inputDelay: Math.round(a.inputDelay),
    processing: Math.round(a.processingDuration),
    presentation: Math.round(a.presentationDelay),
    script: a.longestScript?.entry.sourceURL || '',
    scriptPhase: a.longestScript?.subpart || ''
  }));
});

The template field assumes your pages expose a template name, for example in a data attribute on the body; grouping by template instead of by URL is what turns thousands of rows into a short list. Once data is flowing, rank target elements within each template by how many poor values they produce. The output you want is a handful of elements, each with a dominant phase, a typical loading state and a likely script, that you can hand to whoever owns that code.

The script column depends on the Long Animation Frames API, usually shortened to LoAF. It reports frames whose rendering was delayed past 50 milliseconds and, for each script that ran longer than 5 milliseconds in such a frame, its source URL, function name and how it was invoked (an event listener, a callback such as a timer, a resolved promise, a script being evaluated). That is often enough to separate first-party code from a third-party tag without reproducing anything. Two limits apply: it ships only in Chromium browsers (Chrome and Edge 123 and later), and it attributes time to the script's entry point, which is not always where the slow code lives.

Since Safari 26.2 added the Event Timing API in December 2025, a RUM script can collect INP values from recent Safari versions too, though without LoAF data. CrUX counts only eligible Chrome users, so expect your RUM numbers and Search Console's to differ.

Reproducing it in DevTools

With a target element and a phase in hand, reproduce the interaction in Chrome DevTools. The Performance panel opens on a live metrics view that shows your local INP as you click around, lists each interaction with its phase breakdown and, once field data is enabled, compares your local values with CrUX for the same page. It can also suggest CPU and network throttling based on that field data. Use it, because a fast developer laptop without CPU throttling can hide problems that users on mid-range phones hit every day.

Then record a trace while performing the slow interaction. Interactions appear on their own track in the recording, with the main thread activity underneath showing what ran during each phase. If your field data says the slow interactions happen while the page is still loading, interact during load in the lab as well, which is also what web.dev recommends.

Lighthouse needs a caveat. A standard Lighthouse run loads the page and never interacts with it, so it cannot report INP. It shows Total Blocking Time (TBT) instead, and web.dev words the relationship carefully: TBT "may be a reasonable proxy metric for INP, but it's not a substitute." A team can push TBT down and watch field INP stay put, because the interaction hurting users happens after load on an element Lighthouse never touched. Lighthouse's timespan mode, in which you perform or script the interactions during the recording, can report INP for those interactions, which makes it useful for regression tests of known journeys.

Fixes, matched to the phase

Yield so input can jump the queue

Input delay is time spent waiting behind other tasks. Any task over 50 milliseconds blocks the main thread, and an interaction that arrives during it has to wait. Splitting the work into smaller tasks and yielding between them lets the browser handle input in the gaps. The helper from web.dev's guide to optimizing long tasks uses scheduler.yield() where the browser has it and falls back to setTimeout:

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

async function processItems(items) {
  for (const item of items) {
    processItem(item);
    await yieldToMain();   // give pending input a chance between items
  }
}

scheduler.yield() is supported in Chromium browsers from version 129 and in Firefox from version 142, but not in Safari, hence the feature detection. The setTimeout fallback still yields; it just puts the continuation at the back of the task queue instead of giving it priority. For very short items, yield every few milliseconds of work rather than after each one.

Paint the response first, do the rest after

INP stops at the next paint, not when all the work is done. A handler that changes only what the user needs to see, and defers everything else until after the frame is painted, gets credit for the fast visual response. web.dev's INP optimization guide uses the same principle: only the work needed for the next frame has to happen before it. Combining requestAnimationFrame with setTimeout pushes the rest past that paint:

function afterNextPaint(callback) {
  requestAnimationFrame(() => setTimeout(callback, 0));
}

filterButton.addEventListener('click', () => {
  filterButton.classList.add('is-loading');   // the only work before the paint
  afterNextPaint(() => {
    const results = applyFilters(currentState);
    renderResults(results);
    sendAnalytics('filter_applied');            // telemetry never blocks the paint
  });
});

If applyFilters is itself a long task, break it up with yieldToMain as well, or a later interaction will queue behind it.

Take third-party work off the critical path

Tag managers, consent platforms, chat widgets, A/B testing tools and session recorders share the main thread with your handlers. Some attach their own click listeners, which then add to the processing time of your interactions. A tag audit aimed at main-thread time goes like this:

  • List every tag that fires on the failing template and the trigger it fires on. Tags triggered by all clicks run inside your interactions.

  • Match that list against the script URLs in your RUM attribution data, so decisions rest on measured cost and not on reputation.

  • Remove tags that nobody can name an owner or a current use for.

  • Move tags that do not need to run at page load to a later trigger, such as after the load event.

  • Ask whether a tag's work can move server side, so the browser runs less of it.

  • Check the consent platform on its own. Its scripts run at the moment many visitors make their first tap, on the banner itself.

  • Re-measure after each batch of changes, so you know which removal helped.

How tags are deployed also changes how much code runs. The comparison of gtag.js and Google Tag Manager covers that choice from the setup side.

Hydration on framework sites

On server-rendered pages built with a client-side framework, the HTML looks ready before the JavaScript has attached its event handlers. A tap during hydration waits for that work, which shows up in attribution data as input delay, often with a loading state other than complete. Shipping less JavaScript, hydrating the visible interactive parts first and leaving static sections unhydrated all shorten that window. The JavaScript SEO guide covers the crawling and rendering side of the same trade-off.

Rendering cost

Presentation delay grows with the amount of DOM a change touches. A handler that re-renders a list of several thousand nodes, or reads a layout property such as offsetHeight right after changing styles (which forces the browser to calculate layout on the spot), pays for it before the frame can paint. Virtualize long lists, batch DOM reads before writes, and apply content-visibility: auto to long offscreen sections so the browser skips their rendering work until they approach the viewport. Layout stability is a separate metric, but the same template changes often affect both; the guide to Cumulative Layout Shift covers that side.

Confirming the fix in the field

A faster interaction in DevTools is a prediction. The confirmation comes from the same RUM data that found the problem: the share of poor values for that target element should drop within days of the release, per template and device. CrUX moves slower. PageSpeed Insights and Search Console both report a rolling 28-day window, so a fix shipped today is fully reflected only about four weeks later, and the validation flow in Search Console runs its own 28-day monitoring period.

Say so before the release. Stakeholders who expect the report to turn green within a week will read the lag as failure, especially when a lab score has already improved. INP work also needs engineering time on templates that marketing teams do not control, which is the cost at the heart of the piece on whether Google has set web standards too high.

A practical first step: add the attribution snippet to one failing template, collect a week of data, and sort the poor values by target element and phase. That sorted list is the work plan.