INP is a main-thread problem
Why Interaction to Next Paint punishes long tasks rather than slow servers, how to find the blocking work in a trace, and the four fixes that move the number.
In one sentence
Interaction to Next Paint measures how long the main thread makes a user wait after they touch something — so it is fixed by doing less work, not by faster servers.
Why it matters
INP replaced First Input Delay as a Core Web Vital because FID only measured the delay before a handler started. That flattered almost every site: input delay is usually a few milliseconds, while the handler itself and the render it triggers are where the seconds go.
INP measures the whole thing, end to end:
| Phase | What it covers | Typical culprit |
|---|---|---|
| Input delay | Waiting for the main thread to be free | A long task already running |
| Processing | Your event handlers | Synchronous state updates, JSON.parse |
| Presentation | Style, layout, paint, composite | Layout thrash, a huge re-render |
The score is not an average. The browser records every interaction and reports roughly the worst one (the 98th percentile once a page has enough interactions). One bad modal open ruins the page's number even if a hundred button clicks were instant.
Thresholds, for reference:
- Good: 200 ms or less
- Needs improvement: 200–500 ms
- Poor: over 500 ms
Finding the blocking work
Field data tells you that you have a problem; a local trace tells you where. Start with the field, because the lab will not reproduce a slow Android phone on your laptop.
// Log the worst interactions from real users, with the element that caused them
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration < 200) continue
console.warn('slow interaction', {
duration: Math.round(entry.duration),
name: entry.name, // 'pointerdown', 'keydown', …
target: (entry as PerformanceEventTiming).target?.nodeName,
})
}
}).observe({ type: 'event', durationThreshold: 200, buffered: true })
Then reproduce it in DevTools with 4× CPU throttling on, record a performance trace, and click the offending control. You are looking for a single long yellow block. Anything over 50 ms is a long task; anything over 200 ms is your INP.
The four fixes that actually move the number
1. Yield between chunks of work. The most common shape is a handler that does everything before the browser gets a chance to paint. Split it: do the visible part first, yield, then finish.
async function onFilterChange(value: string) {
setQuery(value) // cheap, paints immediately
await new Promise((resolve) => setTimeout(resolve, 0))
setResults(expensiveFilter(value)) // the slow part, after a paint
}
scheduler.yield() is the purpose-built version of that setTimeout where it is
available, and unlike setTimeout it keeps your continuation at the front of the
queue rather than the back.
2. Separate the urgent update from the expensive one. Rendering a large list
in response to typing is the canonical INP failure. In React, useDeferredValue
lets the input update at full speed while the list catches up.
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
// Input paints on every keystroke; the 5,000-row list re-renders when it can.
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Results query={deferredQuery} />
</>
)
3. Stop reading layout inside handlers. offsetHeight, getBoundingClientRect,
and friends force a synchronous layout. In a loop that also writes styles, you get
layout thrash — one forced reflow per iteration. Read everything first, then write
everything.
4. Ship less JavaScript to the interaction. A component that only matters
after a click does not need to be in the initial bundle, and a third-party script
that runs analytics on every pointerdown is competing with your handler for the
same thread.
Common pitfalls
- Optimising the wrong percentile. A median of 90 ms with a p98 of 900 ms is a poor INP. Chase the tail.
- Testing on the machine you wrote the code on. A desktop CPU hides almost every INP problem. Throttle, or you will conclude the metric is broken.
- Debouncing the handler instead of the work. Debouncing input delays the visible feedback too, which the user experiences as the same lag with extra steps.
- Blaming the network. INP has no network phase. If the number is bad, the work is on the main thread.
Further reading
- Interaction to Next Paint — the normative definition and thresholds.
- Optimize long tasks — the
yielding patterns, including
scheduler.yield(). useDeferredValue— what it does and, importantly, what it does not.