Core Web Vitals: What INP Really Measures, and How scheduler.yield() Saves the Main Thread
Core Web Vitals: What INP Really Measures, and How scheduler.yield() Saves the Main Thread
In the article on microinteractions, I showed why transform and opacity animate smoothly even on a weak phone — because the browser handles them entirely at the compositing stage, on a separate GPU thread, without touching layout. That's still true, but there's an assumption hiding behind it that's worth exposing now: for that animation to start at all, the browser first has to handle the click event on the main thread — the same thread your entire JavaScript runs on. If, at the moment of the click, the main thread is busy running a long, synchronous task — filtering a large array, parsing an API response, rendering a complex component tree — the click event won't be handled a moment sooner than that task finishes. The animation itself can be as cheap as it gets. It doesn't matter if nobody's gotten around to starting it yet.
Interaction to Next Paint (INP) is the Core Web Vitals metric that measures exactly this gap — not time to first byte, not page load time, just the time between the user pressing anything and the moment the browser actually paints the effect of that interaction on screen. It replaced the older FID (First Input Delay) metric in March 2024 for one important reason: FID only measured a page's first interaction, so a page could score great even if every click after the first one stuttered for a full second. INP takes into account every interaction across the page's entire lifecycle and, in practice, reports the worst recurring case — you can't "fix" it with a good first impression.
Why JavaScript can't be interrupted halfway through
The key mechanism, without which INP wouldn't make sense: JavaScript in the browser runs on a run-to-completion model — once a task starts, it runs from beginning to end, uninterrupted, no matter what the user tries to do in the meantime. A click that happens while such a task is running isn't ignored — it lands in a queue and waits for the thread to free up. A task that takes longer than 50ms is formally classified as a long task (Long Tasks API) — that's not an arbitrary number, but an approximation of the threshold above which users start to subjectively feel that something is "hanging," similar in spirit to the Doherty threshold from the microinteractions article, just measured on the computational-cost side rather than the network side.
INP breaks down into three components, and long tasks most often bloat the first one:
- Input delay – the time from the click until the main thread even starts handling the event. This is exactly where a long task blocks everything.
- Processing time – how long your actual event handler takes to run.
- Presentation delay – the time from the handler finishing to the frame with the resulting effect actually being painted.
Yielding: how to hand the thread back to the browser mid-work
Since a task can't be interrupted from the outside, the only option is to interrupt it deliberately from the inside — splitting long, synchronous work into smaller chunks and handing control back to the browser between them, so it has a chance to handle pending input and paint a frame before you pick up the next chunk of work.
javascript
async function processInChunks(items, processItem) {const results = [];for (let i = 0; i < items.length; i++) {results.push(processItem(items[i]));if (i % 50 === 0) {if ("scheduler" in window && "yield" in scheduler) {await scheduler.yield();} else {// Fallback: setTimeout(fn, 0), NOT Promise.resolve().then()await new Promise((resolve) => setTimeout(resolve, 0));}}}return results;}
A detail that's easy to miss and that genuinely undermines this technique: await Promise.resolve() or queueMicrotask() does not hand control back to the browser. Those are microtasks — the microtask queue gets drained completely before the browser even considers handling pending input or painting a frame. If your async loop uses only microtasks to "split up" the work, from the browser's perspective it's still one continuous task — just broken up into then() calls instead of plain lines of code. To actually yield the thread, you need a macrotask boundary — setTimeout, MessageChannel (faster than setTimeout(0), since it skips the minimum delay browsers impose on nested timeouts), or the new, purpose-built scheduler.yield() from the Scheduler API, which additionally prioritizes the continuation intelligently against other pending tasks, instead of landing at the back of a plain queue the way setTimeout does.
isInputPending(): don't yield the thread if nobody's waiting for it
Yielding control after every single loop iteration has its own cost — every trip through the event loop carries overhead. If nobody clicked anything at that moment, interrupting the work every 50 iterations is pure waste, stretching out total processing time with no benefit to the user. navigator.scheduling.isInputPending() solves this by asking directly: is there actually an unhandled input event waiting in the queue before you decide to interrupt the work.
javascript
function processQueue(tasks) {while (tasks.length > 0) {if (navigator.scheduling?.isInputPending()) {break; // someone is actually waiting – yield the thread now}doExpensiveWork(tasks.shift());}if (tasks.length > 0) {setTimeout(() => processQueue(tasks), 0);}}
This flips the logic compared to a rigid "every 50 items": instead of guessing upfront how much work is "safe" between one yield and the next, you ask the browser in real time whether there's actually a need right now. With an empty input queue, the loop can keep going uninterrupted for far more than 50 iterations — with a real click coming in mid-processing, it hands back control immediately, exactly where it matters.
Pitfalls and best practices
- Microtasks don't count as a yield. This is the most common mistake when trying to "fix" INP — the code looks asynchronous because it has an
await, but if all thatawaitis waiting on isPromise.resolve(), the browser still treats the whole thing as one uninterruptible task. scheduler.yield()still needs a fallback. Support is currently limited mostly to Chromium-based browsers — treat it as progressive enhancement, withsetTimeoutas the safe fallback, exactly like the Paint API in the CSS Houdini article.- Long tasks from third-party scripts count toward your INP score too. Analytics, ads, chat widgets — they all live on the same main thread and block it just as much as your own code does. A
PerformanceObserverlistening forlongtaskentries will surface all of them, regardless of who generated them — it's worth measuring real production traffic (RUM), not just local tests on an empty, cached dev environment. - INP measures the worst recurring case across the page's whole lifecycle, not the first impression. An interaction that only starts stuttering after ten minutes of using the app, once a lot of data has piled up in client state, still counts toward the score — testing only the first few clicks on a freshly loaded page systematically underestimates the real problem.
Conclusion
The microinteractions article showed how to make the animation itself cost the main thread nothing. INP shows the other half of the same equation: even the cheapest animation in the world won't help if the main thread is busy with a long, uninterruptible JavaScript task at the exact moment the user clicked. The run-to-completion model means the only path to responsiveness is deliberately splitting your own heavy work into chunks and handing control back to the browser between them — through a real macrotask, not a microtask, and ideally only when isInputPending() actually confirms someone is waiting. The entire chain of perceived performance, from the Doherty threshold to INP, is only as strong as its weakest link — and increasingly, that link turns out to be neither the network nor the server, but your own JavaScript, left undivided for too long.