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.
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
| Phase | What is happening | Typical cause when it dominates |
|---|---|---|
| Input delay | The tap is queued because the main thread is busy | A long task from hydration, a third-party script, or an analytics flush |
| Processing time | Your event handler is running | Synchronous state updates over a large tree, expensive derivations, a sort over thousands of rows |
| Presentation delay | The browser is producing the next frame | Layout thrash, an enormous DOM, a re-render of the whole page for one changed row |
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 });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.
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.
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));- 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.