Start with one log line.
click INP= 264ms (input 7 + proc 223 + present 35)
I clicked a button once, and the screen took 264 milliseconds to repaint. From the moment a finger lands to the moment anything visible changes, more than a quarter of a second went by with nothing on screen. I edited the button's code and measured again: 56ms. The total CPU work stayed exactly the same. The only thing that changed was when the work paused to let the browser paint.
Most teams watch LCP and CLS. Loading speed and layout shift are easy to notice. But responsiveness after load, how fast a button reacts when you tap it, still gets deferred. I did the same for a long time. So this time I stopped guessing and read the numbers the browser hands you directly. Every log and table below is a real value pulled from the Event Timing API in Chrome 150.
What INP measures: one click, three slices
INP stands for Interaction to Next Paint. It measures the delay from the moment a user presses something to the frame where the result actually paints. The key idea is that a single interaction is not one opaque number. It splits into three slices. Here is how the official web.dev docs define them.
- Input delay: the time before any callback for the interaction runs. If the main thread is busy with other work at that moment, this grows.
- Processing duration: the time it takes your event callbacks to actually execute. A heavy click handler shows up here.
- Presentation delay: the time after the callbacks finish until the next frame paints on screen.
Add the three and you get that interaction's latency. INP reports the (near) slowest interaction across the whole visit. That is the decisive break from the old metric, FID. FID measured only the input delay of the first interaction, the reaction of the very first button you pressed on the page. INP watches every click, tap, and key press, then reports something close to the worst. It grades the whole experience, not the first impression.
The thresholds, per web.dev, are read at the 75th percentile of field page loads.
| INP (p75) | Verdict | How it feels |
|---|---|---|
| ≤ 200ms | Good | responds right away |
| > 200ms and ≤ 500ms | Needs improvement | a slight hitch |
| > 500ms | Poor | feels dead, so you tap again |
One date worth pinning down: INP became a stable Core Web Vital on March 12, 2024, replacing FID (web.dev announcement), and FID was removed from Chrome tooling on September 9 that year. So the single CWV metric representing responsiveness today is INP.
Why a web developer should measure INP now
Two reasons. One is people, the other is search.
The people reason is plain. However fast a page loads, if a button freezes for 300ms on every press, it gets remembered as a slow site. And this overlaps with accessibility. When nothing responds, users with cognitive load or a hand tremor press the same button again, and a form gets submitted twice in the gap. That is exactly why I treat response time the same way I treated measuring and fixing accessibility with Lighthouse.
The search reason needs honesty. Core Web Vitals is part of Google's page experience signals, and INP sits inside it. But Google describes it as a tiebreaker among pages of similar relevance, not something that overrides relevance. Getting INP under 200ms does not guarantee a ranking boost. That is not my opinion; it is the official position. The work still pays off, because the same effort moves a search signal and the actual felt experience at once. Even on one of those alone, it earns its keep.
Keep one property in mind. INP is fundamentally a field metric. It gets graded from data collected in real users' Chrome (CrUX). Lab tools can estimate it, but that value depends entirely on which interactions you perform. So this experiment controls exactly what I pressed and only claims what those conditions produced. It cannot stand in for a real visitor's slow phone. I come back to that limit at the end. This property pairs with the "how fast loading finishes" story from my LCP measurement. LCP owns the front, INP owns the back.
The sandbox: same work, two ways
The setup is deliberately small. One static HTML page with two buttons. Both do exactly 220ms of computation. The only difference is how they spend those 220ms.
The first button runs it in one shot. Inside the click handler it holds the main thread for 220ms and refuses to let go. This is a common pattern in the wild: one click that sorts a list, walks localStorage, and redraws a chart, all inside a single function.
function busy(ms) {
const end = performance.now() + ms;
while (performance.now() < end) { /* hog the main thread */ }
}
document.getElementById('blocking').addEventListener('click', () => {
busy(220); // one 220ms block
document.body.style.background = '#fff7ed';
});
The second button slices the same 220ms into eleven 20ms pieces and hands the main thread back to the browser between each one.
const yield_ = () =>
('scheduler' in window && 'yield' in scheduler)
? scheduler.yield() // supported: prioritized continuation
: new Promise(r => setTimeout(r, 0)); // fallback: setTimeout
document.getElementById('yielding').addEventListener('click', async () => {
for (let i = 0; i < 11; i++) {
busy(20);
await yield_(); // yield after each slice
}
document.body.style.background = '#ecfdf5';
});
I let the browser do the measuring, with the same instrument that collects INP in the field: the Event Timing API. Observe the event type with a PerformanceObserver, and real user interactions arrive carrying an interactionId. From there you compute the three slices yourself.
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (!e.interactionId) continue; // only genuine interactions
const inputDelay = e.processingStart - e.startTime;
const processing = e.processingEnd - e.processingStart;
const presentation = (e.startTime + e.duration) - e.processingEnd;
console.log(e.name, Math.round(e.duration), inputDelay, processing, presentation);
}
}).observe({ type: 'event', durationThreshold: 16, buffered: true });
I opened the page in Chrome 150 and pressed each button three times, for real. A synthetic click from an automation script carries no interactionId, so this experiment never picks it up. Only trusted, actual clicks count here.
How to read the log
Above is the real log printed straight onto the page. The blocking button (top three groups) and the chunked button (bottom three) split cleanly. Here are the representative values.
| Approach | Representative INP | Input delay | Processing | Presentation | Verdict |
|---|---|---|---|---|---|
| One 220ms handler | 264ms | 7 | 223 | 35 | Needs improvement |
| One 220ms handler (worst) | 376ms | 1 | 220 | 155 | Needs improvement |
| Chunked with scheduler.yield | 56ms | 0 | 21 | 35 | Good |
| Chunked with scheduler.yield | 48ms | 0 | 20 | 28 | Good |
On the blocking side, proc (processing) landed in the 220ms range as one lump. The browser could not paint a frame until the whole click handler finished. All three runs cleared 200ms and fell into "needs improvement."
On the chunked side, the processing charged to a single event is about 20ms. It still does the full 220ms of computation, but the instant the first slice ends and yields, the browser gets a gap to paint, and the interaction closes at 56ms. Same work, 4.7× faster response. The CPU did not get lazier; it just stopped stealing the browser's chance to draw.
There is one more thing worth noticing in the log. A single click bundles three events (pointerdown, pointerup, click) under one interactionId. On the blocking button, pointerup shows zero processing yet a 258ms presentation delay. The computation happened in the click handler, but by holding the main thread it also pushed out the frame after pointerup. INP takes the longest single event in that bundle as the interaction's representative. That is why you sometimes get "the handler itself is fast, so why is INP high?" The answer is usually other work hogging the main thread nearby.
The usual suspects that eat INP in production
My sandbox planted a 220ms loop on purpose, so the cause was obvious. On a real site that 220ms is rarely in one place. It's scattered across pieces, which makes it harder to find. Here are the culprits I keep running into, both while measuring and while poking at other people's pages.
First, hydration and re-renders. A page built with React or Vue hydrates right after load: JavaScript attaches events to the DOM and reconciles state. If that work is heavy, a click the user makes in the meantime waits until hydration finishes. That is the textbook case of a bloated input delay. Add a click that re-renders half the component tree, and processing swells too. "Fast framework" is exactly where people get complacent.
Second, third-party tags. Analytics scripts, ads, chat widgets, heatmap tools. These are usually someone else's code, so you can't chunk them, and they do their work on the main thread whenever they please. If the user presses a button at that exact moment, input delay spikes. It's a common reason INP grades poorly even when your own code is clean. This shares a root with the CSR habit of injecting content late with JavaScript: an empty page for crawlers, a slow response for users. Work you defer onto the main thread always has a price.
Third, a heavy shared handler behind event delegation. Attaching one listener at the top of the document to catch every click is convenient, but if that handler does heavy branching and computation on each click, every click gets slow.
Fourth, an oversized DOM. A page with tens of thousands of nodes pays that much more for style recalculation and layout on a single click. This tends to surface as presentation delay: the callback finished quickly, but the browser struggles to paint the frame. If you run an infinite-scroll list or a giant table, reach for virtualization to cut the number of nodes you actually render.
The point is not to jump straight to "my click handler is heavy" the moment INP is bad. As the log showed, the computation can happen in one place while the delay gets charged to a different event. Look at which of the three slices is large first, then prescribe. Poke by guesswork and you'll keep polishing a perfectly fine handler while the real bottleneck, a third-party tag, sits untouched. Measure first, fix second.
Chunking long tasks with scheduler.yield
scheduler.yield() does what the name says: it yields the main thread to the browser. It gives the browser room to handle queued rendering or pending input, then resumes execution right where your function left off. Anything over 50ms is a long task by web.dev's definition, and a long task cannot accept input for its whole duration. Break the long task up, and input delay and presentation delay both come down.
setTimeout(fn, 0) also yields. But there is a difference. The remainder you hand to scheduler.yield() goes into a queue with slightly higher priority than brand-new tasks, so it resumes without getting shoved behind unrelated work that cut in. setTimeout gives no such guarantee; some other timer can jump ahead during the gap.
Now the honest caveat. scheduler.yield() is not Baseline yet. It does not run in every widely used browser. That is why the code above wraps it in progressive enhancement: use the prioritized continuation where it exists, fall back to setTimeout where it does not, and still get the basic "yield" effect. Put feature detection in front so the app never breaks on a browser that lacks it.
One more. Chunking is not always the answer. If the computation is genuinely heavy, get it off the main thread in the first place. Move it to a Web Worker, precompute the result, or simply don't do that work at that moment. scheduler.yield() is a tool for keeping work that must run on the main thread responsive by slicing it. It is not a spell that makes heavy work light.
A checklist you can apply today
Here is the order I settled on after measuring.
- Look at the field first. Check your real INP p75 in the Search Console CWV report or CrUX. Start from lab numbers and you fall into "it's fine on my fast laptop, so why is the field bad?"
-
Isolate the slow interaction. Record the problem interaction in the DevTools Performance panel, or attach the Event Timing API in production and log the three slices for events that carry an
interactionId. The fix differs depending on whether input, processing, or presentation dominates. - If input delay is large, find the other work (heavy init, third-party scripts, timers) hogging the main thread at that moment and defer or chunk it.
-
If processing is large, the handler itself is heavy. Split the long task with
scheduler.yield(), and push non-urgent parts (logging, analytics beacons) to after the interaction. - If presentation delay is large, check whether your callback thrashes layout or touches too much DOM. Too much to draw in one frame pushes presentation out.
-
Avoid: doing everything synchronously on one click, kicking off a heavy re-render immediately after an interaction, and calling
scheduler.yield()with no feature detection.
The honest limit
This experiment is a lab measurement on Chrome 150, desktop, a fast machine. Field INP spreads far wider, down to low-end Android. So these numbers (264ms to 56ms) are enough to show the direction and the mechanism (chunking makes the response faster), but they are not a prediction of your site's field INP. And as I said, making INP good guarantees no ranking gain. Core Web Vitals has never beaten relevance. Strip those two away and one real benefit remains: when someone actually using your site presses a button, the screen answers in 56ms instead of 264ms. That alone is worth measuring.
If you need structured data emitted reliably server-side, or a real measurement pass on an existing site's Core Web Vitals and accessibility, I take that on personally as consulting and implementation work. When this kind of measure-and-fix job comes up, the contact route on my profile is the way to reach me.

Top comments (0)