devShakib

The Interaction to Next Paint Trap Nobody Warned Me About

Interaction to Next Paint (INP) is the Core Web Vital that fails JavaScript heavy apps. Learn why FID lied and how to fix INP with yielding, Web Workers, and RUM.

A client dashboard I helped ship last year had a green Lighthouse score. Ninety-something on performance, all the Core Web Vitals badges lit up in CI, and everyone felt good about it. Then the support tickets started: "the filters feel laggy," "the app hangs when I click a row," "typing in the search box drops characters." Every one of those complaints was invisible in our lab reports. The lab loaded the page once and walked away. Real users clicked things.

That gap has a name now, and it's the reason I stopped trusting my old performance instincts. Interaction to Next Paint quietly became a Core Web Vital in March 2024, replacing First Input Delay, and it measures the exact thing FID was too polite to measure: how long the page actually feels frozen when you interact with it. If your app is JavaScript-heavy — and almost every app I build with a modern framework is — INP is the metric most likely to knock you from "good" to "poor" without changing a single line of your loading code.

This post is the field guide I wish I'd had before that dashboard shipped: what INP actually measures, why the main thread is the whole story, how to instrument it in production, and the concrete techniques — yielding, Web Workers, virtualization, framework primitives — that drag a page from "poor" to "good."

What is Interaction to Next Paint (INP)?

Interaction to Next Paint is a Core Web Vital that measures how responsive a page feels across its entire lifecycle. Every time a user clicks, taps, or presses a key, the browser records how long it takes from that interaction until the next frame is painted with the visual result. INP then reports (roughly) the worst of those interactions for the whole visit. In plain terms: it's the metric that answers "when I click things on this page, how long does it stay frozen before something happens?"

That's a very different question from the loading metrics most of us obsess over. Largest Contentful Paint and First Contentful Paint measure how fast the page shows up. INP measures how fast the page responds once it's there. You can ace every loading metric and still ship an app that feels broken the moment someone starts using it — which is precisely what happened to me.

Why FID lied to us and INP tells the truth

FID (First Input Delay) measured one thing: the delay between a user's first interaction and the moment the browser could start processing it. That's it. Start. It didn't measure how long the event handler ran, it didn't measure the render afterward, and it only ever looked at the first interaction on the page.

That design flaw made FID absurdly easy to pass. The first click on most pages happens while the main thread is relatively quiet, so the delay is tiny. I've shipped pages with a 12ms FID that were genuinely painful to use after that first click — every subsequent filter, sort, and expand froze the tab for a third of a second. FID gave those pages a gold star.

INP fixes the three lies at once:

The INP thresholds you actually need to hit

The thresholds are blunt: 200ms or under is "good," 200 to 500ms is "needs improvement," and over 500ms is "poor." That 200ms number is the whole game, and I'll come back to why it's a cliff and not a gentle slope. Google scores your site on the 75th percentile of interactions across real visits, so a handful of fast clicks won't save you if your worst common interaction is slow — the tail is what gets measured.

The main thread is a single cashier

Here's the mental model that finally made INP click for me. The browser's main thread is one cashier at one register. It handles your JavaScript, your event handlers, your style recalculation, your layout, and your paint — all of it, in a single line, one task at a time. There is no second register.

When a user clicks, three things have to happen at that one register, in order:

INP is the sum of all three. This is why "my handler is fast" is not a defense. I've had handlers that ran in 8ms sit behind 180ms of input delay because an unrelated analytics script was chewing on the thread when the click landed. The user felt 190ms of lag and blamed my button.

Once you see the single-cashier model, the whole discipline becomes obvious: stop putting long jobs at the register while a human might be standing in line. Every INP fix in this post is a variation on that one sentence.

Long tasks, hydration, and the 200ms cliff

A "long task" is any chunk of main-thread work that runs for more than 50ms without yielding. During a long task, the cashier is heads-down and cannot respond to input. Every long task is a window where a click can get stuck in the input-delay phase. When people talk about "blocking the main thread," this is the concrete thing they mean: a single uninterrupted 300ms function that ties up the register.

The single worst offender I run into is hydration. Server-rendered frameworks send HTML that looks interactive, then ship a bundle that walks the whole tree attaching event listeners and rebuilding component state. On a mid-range Android phone — the device most of your users actually hold — that hydration pass can be a 300–600ms long task. The page looks ready. The user taps. Nothing happens for half a second because the cashier is mid-hydration. This is the cruelest INP bug because the interface looks done; the interactivity just isn't wired up yet.

Why 200ms is a cliff, not a slope

The 200ms threshold is a cliff because of how frames work. Screens refresh every ~16ms at 60Hz. To feel instant, an interaction should paint within a frame or two. Once you blow past 200ms, you're not "a little slow" — you've crossed the line where the human brain registers the UI as broken rather than responsive. There's no partial credit. A 210ms interaction and a 490ms interaction land in the same "needs improvement" bucket, and both feel like the app stuttered. Optimizing from 490ms to 460ms is invisible to a user and invisible to your score; getting under 200ms is the only jump that changes the rating.

Field data over lab data, every time

The mistake I made on that dashboard was trusting lab data. Lighthouse, WebPageTest, local DevTools traces — these load a page in a controlled environment and estimate. They're great for debugging a specific interaction you already know is slow. They are useless for discovering which interactions your real users find slow, because the lab never clicks the filter that reads 40 rows and re-renders a table.

INP is a field metric. It only means something when measured on real devices, on real networks, doing real things. This is why Real User Monitoring (RUM) is non-negotiable for INP — synthetic testing can confirm a fix, but only field data tells you what to fix. So I instrument it in production with the web-vitals library and ship the numbers somewhere I can query them.

import { onINP } from 'web-vitals';onINP((metric) => {  // metric.value is the INP in ms  // metric.attribution tells you WHICH element and phase was slow  const { interactionTarget, inputDelay, processingDuration, presentationDelay } =    metric.attribution;  navigator.sendBeacon('/rum', JSON.stringify({    value: metric.value,    rating: metric.rating,          // 'good' | 'needs-improvement' | 'poor'    target: interactionTarget,      // e.g. 'button#apply-filters'    inputDelay,    processingDuration,    presentationDelay,    url: location.pathname,  }));});

That attribution block is the part people skip, and it's the most valuable data you'll get. It tells you the element that was slow (button#apply-filters) and which phase ate the time. When I finally shipped this on the dashboard, the answer was embarrassingly clear: 80% of our poor interactions were one "Apply filters" button, and 70% of its time was input delay — the handler barely ran, but the thread was always busy when users clicked it. I'd have never guessed that from a lab trace.

Use sendBeacon (or web-vitals' built-in reporting) so the data survives the page unload. Then look at the 75th percentile, not the average. Averages hide the tail, and INP is scored on the tail. A p50 of 90ms can sit right next to a p75 of 340ms — and it's the p75 that fails you.

How to fix INP: yielding to the browser

Once you accept the single-cashier model, the fix is philosophically simple: chop long tasks into short ones and hand the register back to the browser between chunks, so a waiting click can jump the queue.

The old trick was await new Promise(r => setTimeout(r, 0)) to break up work. It works, but setTimeout yields to the back of the task queue, so your continuation can wait behind lower-priority junk. The modern tool is scheduler.yield(), which yields but keeps your continuation at high priority so it resumes promptly after pending input is handled.

async function processRows(rows) {  for (const row of rows) {    doExpensiveWork(row);    // Yield if we've held the thread too long, so pending    // clicks/keystrokes can be handled between chunks.    if (navigator.scheduling?.isInputPending?.()) {      await scheduler.yield();    }  }}

Two APIs are doing the heavy lifting here:

If your target doesn't support scheduler.yield() yet, feature-detect and fall back so you don't crash older browsers:

function yieldToMain() {  if (globalThis.scheduler?.yield) {    return scheduler.yield();  }  return new Promise((resolve) => setTimeout(resolve, 0));}

One caveat I've been bitten by: yielding is not free, and yielding on every loop iteration can make a batch job dramatically slower overall because you keep round-tripping through the event loop. The isInputPending() guard is what keeps this sane — you only pay the yield cost when a human is genuinely waiting to be served. When nobody's clicking, the loop runs full speed.

Offload heavy work to a Web Worker

The other big lever is not doing the work on the main thread at all. Heavy parsing, sorting, diffing, or CSV crunching belongs in a Web Worker — a genuinely separate thread with its own register. On one project we were parsing a 4MB JSON export and rebuilding a lookup map on every import, which locked the tab for ~700ms. Moving that into a worker and posting back the finished map meant the UI never stalled; the main thread only paid for the postMessage handoff.

// worker.js — runs off the main threadself.onmessage = ({ data }) => {  const parsed = JSON.parse(data.raw);  const lookup = buildLookupMap(parsed); // the expensive part  self.postMessage(lookup);};// main.js — the UI thread stays responsiveconst worker = new Worker('/worker.js');worker.onmessage = ({ data }) => renderTable(data);worker.postMessage({ raw: hugeJsonString });

The catch is that workers can't touch the DOM and everything you send gets structured-cloned, so they pay off for pure computation, not for DOM shuffling. If the "work" is mostly building and mutating DOM nodes, a worker won't help — you have to solve that on the main thread with virtualization instead. And for very large payloads, watch the clone cost: transferable objects (like ArrayBuffer) can move data without copying when you need it.

For the "I need to update state but it's not urgent" case in React, useTransition and useDeferredValue mark work as interruptible so a keystroke can preempt a big re-render instead of queuing behind it.

The framework tax on INP

Every framework charges rent at interaction time, and it helps to know the bill before you sign.

| Framework | What it costs you at interaction time |

|---|---|

| React (client-rendered) | Reconciliation runs on the main thread; a single setState can re-render a large subtree synchronously. Concurrent features (useTransition, useDeferredValue) exist specifically to keep this off the critical path — but they're opt-in, and most code doesn't use them. |

| React (SSR/RSC) | Adds hydration: a big up-front long task attaching listeners. Streaming and selective hydration help, but the tax is real on first interactions. |

| Vue | Fine-grained reactivity means updates are usually more surgical than React's, so less wasted re-render — but a giant reactive list or a heavy watch can still block. |

| Svelte | Compiles away the runtime and updates surgically, so it typically has the lightest interaction tax of the big three — you're mostly paying for your own logic, not framework overhead. |

| Signals (Solid, Angular signals, Preact) | Update only the exact DOM nodes that changed. This is the direction the whole ecosystem is moving, and for good INP reasons. |

I'm not telling you to rewrite your app in Svelte. I ship plenty of React and I'd do it again. The point is that the framework's default behavior is rarely tuned for INP, and the parts that are tuned for it — concurrent rendering, deferred values, islands, selective hydration — are opt-in features you have to reach for deliberately. Nobody hands them to you.

The cheapest framework win, regardless of stack: stop re-rendering things that didn't change. Memoize the expensive list. Virtualize the long table. Move the derived-value computation out of the render path. Most poor INP scores I've fixed were not exotic — they were a click that re-rendered ten thousand rows when only one changed.

Don't forget the third-party tax

The other tax nobody budgets for is third-party scripts. Analytics, chat widgets, tag managers, A/B testing snippets, session recorders — they all run on your one main thread, and they fire long tasks on timers you don't control. On the dashboard, a marketing analytics script that ran a 150ms task on an interval was responsible for most of my input delay, and it had nothing to do with any button I wrote. Load these lazily, at low priority, and audit them the same way you'd audit your own code. A slow tag manager is your INP problem whether you like it or not.

A before/after INP teardown

Back to the dashboard. Here's the actual arc of dragging one page from "poor" to "good."

Before (p75 INP: ~420ms). The offender was the "Apply filters" button on a table of ~3,000 rows. The click handler:

The attribution data told the whole story: input delay ~180ms, processing ~90ms, presentation ~150ms. All three phases were bad, which is unusual and honestly a gift — it meant every fix would move the needle.

What I changed:

After (p75 INP: ~140ms). Input delay dropped to ~40ms, processing to ~30ms, presentation to ~70ms. No visual redesign. No new feature. The page just stopped freezing when people used it, and the support tickets about "laggy filters" stopped within a week of the release.

The lesson I keep relearning: I didn't chase a score. I read the attribution, found the single worst interaction, and rebuilt the main thread around it. The score followed. If you optimize for the number instead of the interaction, you'll spend a week shaving 20ms off things nobody clicks while the one button that matters stays broken.

The INP checklist I run before shipping any interactive page

This is the list I actually work through now, not a wish list.

Key takeaways