Why INP Optimization Is Harder Than FID Ever Was
Plenty of sites woke up to a failing Core Web Vital without changing a line of code, purely because the metric changed underneath them. First Input Delay was a generous metric — it measured only the delay before the browser began processing your first interaction, and it ignored everything you did afterwards. A site could take two seconds to render a response and still score a perfect FID.
Interaction to Next Paint closed that loophole. It measures all interactions across the page’s life, not just the first, and it times the full round trip: input delay, your event handler, and the rendering work before the user actually sees a change. INP optimization is harder because there is nowhere left to hide — the metric now covers the part where the real work happens.
The Three Phases, and Which One Is Yours
Every interaction breaks into three parts, and knowing which one dominates tells you what to fix. Guessing wrong here is the most common way to spend a week on nothing.
Input delay is the wait before your handler runs at all, because the main thread was busy with something else. Processing time is your handler executing. Presentation delay is everything from handler completion to the pixels changing — style, layout, paint.
Most teams assume their handler is the problem and start optimising it. In practice input delay is very often the culprit, and the offending task usually has nothing to do with the interaction: a third-party tag, an analytics flush, a hydration pass, a framework re-render triggered by something else entirely. You are not slow because your click handler is slow; you are slow because the thread was busy when the click arrived.
Measure the Real Thing First
Lighthouse cannot give you INP. It runs a synthetic load with no user, and INP requires interactions — so a perfect Lighthouse score tells you nothing about whether you pass. This trips people up constantly.
You need field data. The web-vitals library reports the real metric from real sessions, and it will attribute the slow phase for you.
import { onINP } from 'web-vitals/attribution';
onINP(({ value, rating, attribution: a }) => {
// rating: 'good' (<=200ms) | 'needs-improvement' | 'poor' (>500ms)
navigator.sendBeacon('/rum', JSON.stringify({
value, // the INP figure, ms
rating,
target: a.interactionTarget, // CSS selector of the element hit
type: a.interactionType, // 'pointer' | 'keyboard'
inputDelay: a.inputDelay, // phase 1
processingDuration: a.processingDuration, // phase 2
presentationDelay: a.presentationDelay, // phase 3
loadState: a.loadState, // was the page still loading?
}));
}, { reportAllChanges: false });
The thresholds are 200ms for good and 500ms for poor, assessed at the 75th percentile of your real users. That percentile is the point: your own laptop on office wifi is not the 75th percentile of anything. Ship the beacon before you optimise, group by interactionTarget, and let the data name the three components that are actually hurting — it is rarely the ones people nominate in a meeting.
Long Tasks Are the Root Cause
The browser’s main thread is single-threaded and non-preemptive. Once a task starts, nothing interrupts it — not your click, not a paint. So any task over 50ms is a window where the page is simply unresponsive, and if a click lands inside it, that duration is charged straight to your input delay.
The fix is not to make the work faster. It is to break the work up, so the browser gets gaps in which to handle input.
// Bad: 800 rows in one task. Anything the user does during this waits.
function render(rows) {
rows.forEach(row => container.append(buildRow(row)));
}
// Better: yield between chunks so input can be serviced
async function render(rows) {
for (let i = 0; i < rows.length; i++) {
container.append(buildRow(rows[i]));
if (i % 50 === 0) await yieldToMain();
}
}
function yieldToMain() {
// scheduler.yield() keeps your continuation at the FRONT of the queue.
if ('scheduler' in globalThis && 'yield' in scheduler) {
return scheduler.yield();
}
// setTimeout fallback goes to the BACK — correct, but slower overall.
return new Promise(r => setTimeout(r, 0));
}
The distinction in that fallback is worth internalising. The old setTimeout(0) trick works, but it puts your continuation at the back of the task queue behind every other pending task, so yielding often makes the total job noticeably slower. scheduler.yield() returns a promise that resumes at the front of the queue, which means you can yield frequently without the throughput penalty. Where it is unsupported, keep the fallback and yield less often.
For work that is genuinely low priority, scheduler.postTask() with { priority: 'background' } tells the browser it may be interrupted freely. And anything that is pure computation with no DOM access belongs in a Web Worker, off the main thread entirely.
The Optimistic Paint Trick
The single most effective structural change is usually not making anything faster — it is separating the visual response from the work behind it. INP stops counting at the next paint, so paint something immediately and do the rest afterwards.
button.addEventListener('click', async () => {
// 1. Cheap, synchronous, visible. INP's clock effectively stops here.
button.setAttribute('aria-busy', 'true');
showSpinner();
// 2. Let the browser paint before doing anything expensive.
await new Promise(r => requestAnimationFrame(() => setTimeout(r, 0)));
// 3. Now the heavy lifting — no longer inside the interaction.
const data = await fetchResults();
renderResults(data);
button.removeAttribute('aria-busy');
});
This is not gaming the metric; it is what the metric is trying to encourage. The user’s complaint was never “the data took 400ms to load” — it was “I clicked and nothing happened”. Acknowledge the input, then work.
Framework-Specific Traps
Hydration is the classic INP killer, and the loadState field in that beacon is how you catch it. If your poor interactions cluster while the page is still loading, users are clicking a UI that looks ready but whose JavaScript has not attached yet — the click queues behind a hydration task and eats hundreds of milliseconds. Server components and streaming help by shrinking how much must hydrate at all, which is part of the argument in our server components versus server actions guide.
In React specifically, useTransition and useDeferredValue exist for exactly this: mark the expensive re-render as non-urgent so the typed character paints immediately and the filtered list catches up. A controlled input that re-renders a thousand-row table on every keystroke is the canonical failing case, and it is fixed by marking the list update as a transition rather than by memoising harder. If your interaction is a navigation, the View Transitions API is relevant too — but be careful, since a transition animating expensive layout adds directly to presentation delay.
Third-party scripts deserve suspicion by default. A tag manager that dispatches synchronous work on every click will wreck INP with no visible trace in your own code, and it is common for the biggest single win to be moving a marketing script behind postTask or off the main thread. Google’s INP documentation has a good breakdown of the diagnostic path if you want to go deeper.
When to Leave It Alone
Chasing INP below the 200ms threshold has sharply diminishing returns. Once your 75th percentile is comfortably good, further work is invisible to users and to ranking alike, and the yielding machinery you add is real complexity that future maintainers must understand. Yielding is not free: split a task too aggressively and you pay scheduling overhead on every chunk.
It is also worth being honest that INP is one lightweight ranking signal among many, and content relevance dwarfs it. Fix INP because a laggy interface is genuinely unpleasant to use — that motivation will lead you to the right fixes. Optimising for the number alone tends to produce sites that score well and still feel bad.
INP optimization comes down to a simple hierarchy. Measure the field data first with web-vitals/attribution, because the failing phase is usually not the one you would guess and Lighthouse cannot tell you at all. Attack input delay before handler speed, since long tasks the user never triggered are the most common cause. Break work into chunks with scheduler.yield(), paint an acknowledgement before doing the work, and treat hydration and third-party scripts as prime suspects. Then stop, once you are safely under 200ms at p75 — and spend the time on something users will actually notice.