How to Fix INP: Diagnose It Before You Optimise

To fix INP, find the interaction that is slow before you change any code. Collect field data with the web-vitals attribution build, read which of the three phases (input delay, processing duration, presentation delay) dominates, then fix that one. Guessing at bundle size rarely moves the 75th percentile, because the interaction hurting you is usually one handler on one page.

Interaction to Next Paint replaced First Input Delay as a Core Web Vital in March 2024. FID only measured the wait before the first event handler ran, which flattered nearly every site. INP watches every click, tap and key press for the whole visit, from the input to the frame that shows the result.

What counts as a good INP score?

Google’s thresholds, taken at the 75th percentile of page loads and split across mobile and desktop: 200 ms or less is good, 200 to 500 ms needs improvement, and anything above 500 ms is poor. web.dev’s INP reference is the canonical statement of that.

Three interaction types are observed and no others: clicking with a mouse, tapping a touchscreen, and pressing a key on a physical or on-screen keyboard. Scrolling, hovering and pinch-zoom do not count. Neither does an interaction that never produces a paint.

Every measured interaction splits into three phases. Input delay is the wait from the user’s action until your first event callback starts. Processing duration is all the event callbacks running to completion. Presentation delay is what is left: style, layout, paint and compositing up to the frame the user sees.

Those three add up to the number. Which one is biggest decides the fix, and they need entirely different fixes.

Field coverage is better than it used to be. The Event Timing API landed in Chrome 76 and Firefox 89, and Safari shipped it in 26.2. The interactionId property that INP actually depends on arrived in Chrome 96, Safari 26.2 and Firefox 144, so real-user data is no longer Chromium-only.

How do you fix INP if you cannot reproduce it locally?

You stop trying to reproduce it and collect attribution from real users instead. The web-vitals library (6.2.0 at the time of writing) ships a separate attribution build that reports the phase breakdown alongside the value.

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

onINP(({ value, attribution }) => {
  const {
    interactionTarget,
    interactionType,
    inputDelay,
    processingDuration,
    presentationDelay,
    longestScript,
    loadState,
  } = attribution;

  navigator.sendBeacon('/rum', JSON.stringify({
    inp: value,
    target: interactionTarget,
    type: interactionType,
    inputDelay,
    processingDuration,
    presentationDelay,
    loadState,
    script: longestScript && {
      url: longestScript.entry.sourceURL,
      fn: longestScript.entry.sourceFunctionName,
      subpart: longestScript.subpart,
      ms: longestScript.intersectingDuration,
    },
  }));
});

interactionTarget is a CSS selector for the element the user hit, which is what turns “our INP is 480 ms” into “the filter dropdown on the category page is 480 ms”. An empty string means the element was removed from the DOM before the library could describe it.

loadState is the one people skip and shouldn’t. If your worst interactions all report loading or dom-interactive, the problem is not the handler at all, it is that people are clicking while hydration and third-party tags still own the main thread.

longestScript is newer and does most of the work for you: it names the single script that overlapped the interaction for longest, and subpart tells you which of the three phases it landed in. The library also exposes totalScriptDuration, totalStyleAndLayoutDuration, totalPaintDuration and totalUnattributedDuration if you want the frame accounted for end to end.

One catch. onINP ignores interactions shorter than its durationThreshold, which defaults to 40 ms. That is usually what you want, but it means quiet pages report nothing at all.

Which script caused it? Read the long animation frame

The Long Animation Frames API, in Chrome since version 123, reports any frame whose rendering update was delayed past 50 ms, and hands you the scripts that ran inside it.

new PerformanceObserver((list) => {
  for (const frame of list.getEntries()) {
    if (frame.blockingDuration < 50) continue;

    for (const script of frame.scripts) {
      console.log({
        invokerType: script.invokerType,
        invoker: script.invoker,
        source: `${script.sourceURL}:${script.sourceFunctionName}`,
        duration: Math.round(script.duration),
        forcedLayout: Math.round(script.forcedStyleAndLayoutDuration),
      });
    }
  }
}).observe({ type: 'long-animation-frame', buffered: true });

blockingDuration is the sum of task durations over 50 ms within the frame, so it approximates how long that frame stopped the browser answering input. invokerType is one of user-callback, event-listener, resolve-promise, reject-promise, classic-script or module-script, which is often enough on its own to tell a third-party tag apart from your own click handler.

forcedStyleAndLayoutDuration is the layout-thrashing number. If a script’s duration is 90 ms and 70 ms of it is forced style and layout, you are not looking at slow JavaScript, you are looking at a handler that reads offsetHeight inside a loop it is also writing to.

Firefox and Safari have not shipped Long Animation Frames as of August 2026, so treat it as a Chromium diagnostic rather than a measurement you can rely on everywhere. The web-vitals attribution build already folds intersecting LoAF entries into attribution.longAnimationFrameEntries where they exist.

Fixing a long input delay

Input delay means the main thread was busy when the click arrived, and your handler had nothing to do with it. Chase whatever is running instead: hydration, tag managers, an analytics bundle parsed at the wrong moment, a setTimeout chain left over from page load.

Where the work is genuinely yours, break it up.

function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }

  return new Promise((resolve) => setTimeout(resolve, 0));
}

async function indexAll(records) {
  for (const record of records) {
    buildIndexEntry(record);
    await yieldToMain();
  }
}

scheduler.yield() returns a promise fulfilled with undefined and queues the continuation at user-visible priority, but boosted: it resumes ahead of other user-visible work rather than joining the back of the queue the way setTimeout does. Your loop keeps its place. It is in Chrome and Edge from 129 and Firefox from 142, with no Safari support as of August 2026, so keep the fallback or install the scheduler-polyfill package.

The setTimeout fallback has a sharp edge worth knowing: after five nested calls the browser starts clamping each one to a 5 ms minimum, so a long chain of them is slower than it looks.

Do not reach for isInputPending(). Chrome’s own long tasks guidance now advises against it, partly because it can return false when a user has in fact interacted, and partly because input is not the only thing worth yielding for.

Fixing a long processing duration

Here the handler is yours and it is doing too much before letting a frame through. Split it in two: the visual acknowledgement the user is waiting for, then everything else after the paint.

saveButton.addEventListener('click', () => {
  setSavingState(true); // the part the user must see immediately

  requestAnimationFrame(() => {
    setTimeout(() => {
      validate(form);
      serialise(form);
      postToApi(form);
    }, 0);
  });
});

The requestAnimationFrame wrapper waits for the next rendering opportunity and the inner setTimeout pushes the rest into a task after it, so the spinner appears in the frame the interaction is measured against and the expensive work lands outside it. INP stops counting at that paint.

Fixing a long presentation delay

Callbacks finished fast and the frame still took 300 ms. That is style, layout and paint, and the usual causes are a very large DOM, a component re-rendering thousands of rows, or CSS that forces the browser to restyle far more than changed.

content-visibility: auto on off-screen sections is the cheapest win, because it lets the browser skip rendering work for content nobody can see yet. Beyond that, cut the number of nodes that change per interaction rather than the number of kilobytes you ship.

Animation belongs in this bucket too. A view transition runs inside the frame the interaction produces, so a heavy one shows up as presentation delay rather than as script time, something worth keeping in mind if you have followed our write-up on cross-document view transitions.

What to do first

Ship the attribution beacon before you touch anything else, and give it a week. Almost every INP problem we see at Whoooop turns out to be two or three named handlers on two or three named pages, and you cannot find those from a lab trace of a page you already know is fine.

Then fix by phase, in this order: loadState: loading first, because startup work degrades every interaction on the page; then the single worst interactionTarget; then presentation delay, which is usually a rendering budget problem rather than a code one and takes longest to unpick. If you would rather hand the whole loop to someone else, that is roughly what our website speed optimisation work consists of.

What is not worth doing is a bundle-splitting sprint because a Lighthouse score said “reduce JavaScript execution time”. Lab tools measure a page load nobody had. INP measures the click somebody actually made.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch