INP & Long Tasks — Yielding to the Main Thread
Your Handler Was Fast, the Main Thread Wasn't
Web developmentweb development / performance
The scene
Your search box filters a 5,000-row table. You profile the keypress handler: 0.9ms. Lighthouse gives you 98. You ship.
Two weeks later, field data (CrUX / web-vitals):
INP p75 = 470ms → "Poor" (good is ≤200ms, poor is >500ms)
Nothing in your handler is slow. So what is 470ms?
INP is not handler duration
INP measures interaction → next paint, in three parts:
- Input delay — the browser got your click but the main thread was busy. Your handler hasn't even started.
- Processing — your listeners actually running.
- Presentation delay — handler done, but style/layout/paint for that frame still has to finish before the pixels land.
Devtools showed you part 2 only. Your 470ms is almost certainly 1 and 3: an analytics beacon serializing, a hydration chunk, or your own 300ms re-render of 5,000 rows blocking the paint you're being measured on.
A task over 50ms is a "long task". Anything a user taps during one eats that whole remainder as input delay.
Build the fix in 5 lines
The cure for a long task is to break it and actually yield:
function yieldToMain() {
if (globalThis.scheduler?.yield) return scheduler.yield();
return new Promise(r => setTimeout(r, 0));
}
async function chunked(items, work, size = 50) {
for (let i = 0; i < items.length; i += size) {
items.slice(i, i + size).forEach(work);
await yieldToMain(); // paint + pending input get a turn
}
}
What each piece teaches:
- scheduler.yield() resumes at the FRONT of the task queue — you keep your place in line. setTimeout(0) goes to the back, so other work can jump ahead. Hence the ordered fallback, not setTimeout everywhere.
- The await is the whole point: it ends the current task. Rendering can only happen between tasks.
- size=50 is a dial. Measure, don't guess: you want each chunk under ~50ms on a mid-tier Android, not on your laptop.
What practitioners get wrong
await Promise.resolve()/ queueMicrotask — microtasks run inside the same task. You yielded nothing. The frame still can't paint.- requestAnimationFrame for heavy work — rAF runs BEFORE paint, so you've just moved the block into the critical frame. Use it for DOM writes, not computation.
- Debouncing input — hides processing cost but does nothing for presentation delay, and the final burst is still one long task.
- Testing on a fast machine. Use Devtools → Performance → CPU throttle 4x, and Web Vitals extension. Lab numbers and INP disagree by design: INP is field, p75, worst interaction of the visit.
- Chasing INP without finding the right interaction. The expensive one is usually a menu open or a tab switch, not the thing you assumed.
Real lever most of the time: render fewer nodes (virtualize the list) before you optimize the loop around them.
Your exercise
In any app you have, open Devtools → Performance, throttle CPU 4x, record, and click your heaviest control. Find the long task in the flame chart, then split one number: how much of the interaction was input delay vs processing vs presentation delay?
Then answer this: if your handler measures 2ms but INP is 400ms, which of the three parts can chunking with yieldToMain NOT fix — and why?