Skip to content
HandbookPublic
Security

INP optimisation

Interaction to Next Paint measures the tap that felt stuck. Its three phases, why React sites fail the middle one, and how to fix each.

Written forFrontend engineers whose pages score well in the lab and feel slow in the hand
Reading time11 min read
Last reviewed2026-09-04

INP records the worst interaction latency in a session — from the moment a user taps to the moment the screen shows the result. It replaced First Input Delay as a Core Web Vital because FID measured only the wait before the handler ran, which flattered almost every site: a page could respond to the first tap in 20ms and then take a second and a half to paint anything.

The important consequence is that INP cannot be measured in a lab run. Lighthouse does not tap. Every INP number that matters comes from real sessions, which is why teams are usually surprised by it.

Three phases, three different fixes

PhaseWhat is happeningTypical cause when it dominates
Input delayThe tap is queued because the main thread is busyA long task from hydration, a third-party script, or an analytics flush
Processing timeYour event handler is runningSynchronous state updates over a large tree, expensive derivations, a sort over thousands of rows
Presentation delayThe browser is producing the next frameLayout thrash, an enormous DOM, a re-render of the whole page for one changed row
Attribution matters: optimising handler code when the problem is input delay changes nothing at all.
ts
import { onINP } from 'web-vitals/attribution';

onINP(({ value, attribution: a }) => {
  navigator.sendBeacon('/rum', JSON.stringify({
    metric: 'INP',
    value,
    target: a.interactionTarget,      // the element that was tapped
    type: a.interactionType,          // pointer | keyboard
    inputDelay: a.inputDelay,
    processing: a.processingDuration,
    presentation: a.presentationDelay,
    route: routePattern(),
  }));
}, { reportAllChanges: false });
The attribution build tells you which phase to work on. Collect it before optimising anything.

Input delay: find the long task that blocked the tap

A task holding the main thread for 300ms means any tap arriving during it waits up to 300ms before your code even starts. On a mid-range Android handset, tasks that take 50ms on a developer laptop routinely take four to six times that.

01
Measure long tasks in the field, with contextA PerformanceObserver on "longtask" reporting duration and attribution, tagged with route and release. The offenders are usually the same three scripts on every page.
02
Break up hydrationHydrating a large tree in one pass is the single biggest source of input delay on a React site. Server components for anything non-interactive, and lazy hydration for below-the-fold widgets, remove the work rather than reschedule it.
03
Defer third-party script past interactionTag managers, chat widgets and heat-mapping tools regularly own the worst long task on the page. Load them after first interaction or on idle, and measure the difference — it is often larger than any change to your own code.
04
Stop shipping the work twiceData serialised into the HTML and then re-fetched on mount pays for the same thing twice, once in parse and once in a request. It shows up as input delay because parsing blocks the thread.

Processing: yield, and render the acknowledgement first

The handler does not have to finish before the user sees something. Paint the state change — the checkbox ticks, the row highlights, the button goes to its loading state — then yield, then do the expensive part. INP stops at the next paint, so this is not a trick: it is the interaction genuinely being acknowledged.

ts
async function onApprove(id: string) {
  setPending(id);                       // visible immediately

  await yieldToMain();                  // let the browser paint

  const rows = await recalculate(id);   // the expensive part
  setRows(rows);
}

const yieldToMain = () =>
  'scheduler' in window && 'yield' in (window as any).scheduler
    ? (window as any).scheduler.yield()
    : new Promise((resolve) => setTimeout(resolve, 0));
Yield after the visible change. scheduler.yield keeps the continuation at high priority where it is supported.
  • React 18 and later: wrap the non-urgent update in startTransition so the urgent one paints first. It is the framework-level version of the same idea.
  • Debounce derived work, not the state that renders. Debouncing the input value makes the field feel broken; debouncing the search that follows it does not.
  • Move filtering and sorting of large lists off the render path. Memoise on the data, not on the component.

Presentation: the DOM you did not need

The last phase is the browser producing a frame. A ten-thousand-node DOM makes every frame expensive regardless of how efficient your handler was.

  • Virtualise long lists. An operations console showing two thousand rows should have around thirty in the DOM.
  • Use content-visibility: auto on long off-screen sections so the browser can skip their layout and paint entirely.
  • Avoid reading layout properties immediately after writing them — offsetHeight after a style change forces a synchronous reflow inside your handler.
  • Keep animations on transform and opacity. Animating width, top or box-shadow drags layout and paint into every frame.