All articles · Performance

INP in Practice: Finding Slow Interactions and Fixing Them

Cover with the article title next to a speed gauge, in light blue on a dark background

A page can load in under a second and still feel sluggish. You tap “Add to cart”, nothing happens, you tap again, and two items land in the basket. That gap between a tap and the next visible change is what Interaction to Next Paint (INP) measures, and since March 2024 it has been one of the three Core Web Vitals, replacing First Input Delay.

INP is harder to fix than the load metrics because it isn’t about one moment. It’s about every click, tap and key press during the visit, and the culprit is usually JavaScript that looked harmless in isolation. This article walks through how the metric is built, how to find the one interaction that drags your score down, and which fixes are worth the effort.

What INP actually measures

For each interaction, the browser measures the time from the input event until the next frame is painted. That latency has three parts:

  1. Input delay: the event can’t run yet because the main thread is busy with something else.
  2. Processing duration: your event handlers run (every listener for that interaction, not just yours).
  3. Presentation delay: style recalculation, layout, paint and compositing before the browser can show the result.

The page’s INP is roughly its worst interaction. On pages with lots of interactions the browser ignores one outlier for every 50, so a single freak event on a long session doesn’t define the score. Hovering and scrolling don’t count; clicks, taps and keyboard input do.

The thresholds are assessed at the 75th percentile of real page visits:

INP Rating
200 ms or less Good
200–500 ms Needs improvement
over 500 ms Poor

That 75th percentile matters. Your fast laptop on office Wi-Fi is not the visit that sets the number; a mid-range Android phone with twenty tabs open is.

Why lab tools miss it

Lighthouse loads the page and doesn’t click anything, so a standard Lighthouse run cannot report INP. Total Blocking Time is the closest lab proxy: long tasks during load are the same kind of work that delays inputs later. A page with a TBT of 1,500 ms almost certainly has an INP problem; a page with a TBT of 50 ms can still have one, because the slow code only runs when someone opens the filter menu.

So start with field data. The Chrome UX Report (which PageSpeed Insights and our own report show when there’s enough traffic) tells you whether you have a problem at the origin or URL level. It won’t tell you which interaction it is. For that you need your own measurements.

Step 1: collect attributed field data

The web-vitals library has an attribution build that tells you what was clicked and where the time went. A minimal setup that sends one beacon per page:

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

onINP(({ value, rating, attribution }) => {
  const body = JSON.stringify({
    value: Math.round(value),
    rating,
    target: attribution.interactionTarget, // a CSS selector for the element
    type: attribution.interactionType, // 'pointer' or 'keyboard'
    inputDelay: Math.round(attribution.inputDelay),
    processing: Math.round(attribution.processingDuration),
    presentation: Math.round(attribution.presentationDelay),
    page: location.pathname,
  });
  navigator.sendBeacon('/rum/inp', body);
});

After a few days, group the beacons by target and sort by the 75th percentile. Poor interactions tend to cluster: a handful of selectors usually account for most of them. Typical suspects are a mega-menu toggle, a product filter, an accordion that re-renders a large list, or a consent banner’s “Accept” button.

The three sub-parts tell you where to look:

  • High input delay means something else was hogging the main thread when the user acted. Look at what runs on timers, third-party tags, hydration that is still going on, or a long task kicked off by the previous interaction.
  • High processing duration means your handlers (or a framework’s re-render) are doing too much synchronous work.
  • High presentation delay points at rendering cost: a huge DOM, expensive CSS, forced synchronous layout, or a big chunk of HTML inserted at once.

Step 2: reproduce it with real attribution

Once you know the element, reproduce the interaction locally with CPU throttling at 4x or 6x. Recent versions of Chrome DevTools show live INP and the interactions behind it in the Performance panel, so you can click the suspect button and immediately see its latency. Record a trace around the click and look at the main thread: the long task under the interaction is your target.

For production debugging, the Long Animation Frames API (available in Chrome since version 123) is the more useful tool. Unlike the older Long Tasks API, it attributes slow frames to the scripts that ran in them, including the source URL and the function that was invoked:

new PerformanceObserver((list) => {
  for (const frame of list.getEntries()) {
    if (frame.duration < 100) continue;
    for (const script of frame.scripts) {
      console.log(script.sourceURL, script.invoker, Math.round(script.duration));
    }
  }
}).observe({ type: 'long-animation-frame', buffered: true });

The attribution build of web-vitals already includes the long animation frames that overlapped the INP interaction, so you can send the worst script’s URL with your beacon. This is how a lot of teams discover that the slow part isn’t their code at all but an analytics tag reacting to every click.

Step 3: fix the right thing

Give the browser a chance to paint

The most effective single change is to stop doing everything inside the event handler. Update the UI that confirms the action first, then yield, then do the expensive work:

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

button.addEventListener('click', async () => {
  button.classList.add('is-loading'); // cheap, visible feedback
  await yieldToMain(); // let the browser paint it
  const results = applyFilters(products); // the expensive part
  await yieldToMain();
  renderResults(results);
});

scheduler.yield() (Chrome 129 and later) continues your task ahead of other queued work, so you don’t lose your place in line; the setTimeout fallback works everywhere but may let other tasks run first. Either way the interaction’s next paint now happens after the cheap class change, not after the whole filter run.

Be honest about what counts as feedback, though. Showing a spinner and then taking three seconds gives you a good INP and a bad experience. Yielding is about not freezing, not about hiding slow work.

Break up long tasks

Anything that loops over hundreds of items synchronously is a candidate. Process in chunks and yield between them:

async function processInChunks(items, fn, budgetMs = 40) {
  let start = performance.now();
  for (const item of items) {
    fn(item);
    if (performance.now() - start > budgetMs) {
      await yieldToMain();
      start = performance.now();
    }
  }
}

This helps input delay for other interactions too, since the main thread is never blocked for long.

Cut rendering cost

If presentation delay dominates, no amount of handler tuning will help. Common causes:

  • Large DOMs. Pages with many thousands of elements make every style recalculation and layout expensive. Virtualize long lists and lazy-render offscreen sections.
  • content-visibility: auto on long, below-the-fold sections lets the browser skip their rendering work until they come close to the viewport. Pair it with contain-intrinsic-size so the scrollbar doesn’t jump.
  • Layout thrashing. Reading offsetHeight or getBoundingClientRect() right after writing styles forces a synchronous layout. Batch reads before writes.
  • Inserting big HTML strings in one go. Render the first screenful, then the rest after a yield.

Framework-specific levers

In React, wrap non-urgent state updates in startTransition so typing and clicking stay responsive while the expensive render is interruptible, and use useDeferredValue for derived lists. In Vue and Svelte, the same principle applies: keep the synchronous reaction small and move heavy computation out of the update path. Hydration is a classic source of input delay right after load; partial or progressive hydration (islands) limits how much JavaScript has to run before the page responds.

Audit third parties

Tag managers often attach click listeners to the whole document. Each one adds processing time to every interaction. Check the long animation frame attribution for third-party script URLs, remove tags nobody uses, and load the rest after the page is interactive. If a vendor’s script consistently shows up in your slow frames, that’s a conversation worth having with the vendor.

Common traps

  • Measuring only on desktop. INP problems are overwhelmingly a mobile story. Segment your data by device class before you conclude you’re fine.
  • Chasing the lab. A green Lighthouse score with a poor field INP is normal, not a contradiction. The lab simply never clicked the slow button.
  • Debouncing the wrong thing. Debouncing a search box reduces work but doesn’t fix a handler that blocks for 400 ms when it does run.
  • Fixing the median. The 75th percentile is what’s assessed. Improvements that only help fast devices barely move it.

A practical order of work

  1. Confirm the problem in field data (CrUX or your own RUM).
  2. Find the two or three worst interaction targets with attributed beacons.
  3. For each, look at which sub-part dominates: input delay, processing or presentation.
  4. Yield after visible feedback, chunk long loops, and cut rendering cost where presentation delay is high.
  5. Remove or defer third-party listeners that show up in long animation frames.
  6. Watch the 75th percentile for a few weeks; CrUX uses a 28-day window, so changes take time to show.

INP rewards a simple discipline: respond first, work second, and never hold the main thread for longer than a frame or two. Our INP guide covers the metric’s definition in more detail, and the Total Blocking Time guide explains the lab signal that usually points in the same direction.

Tags:INPCore Web VitalsJavaScriptmain thread