Short answer
A poor INP is almost always main-thread work standing between a user’s input and the next frame, and the fix starts with working out which of the three phases that work sits in. Find the slow interaction in field data, reproduce it in the DevTools Performance panel, read the INP breakdown, then fix the dominant phase: clear competing tasks for input delay, split and yield for processing duration, and shrink rendering work for presentation delay.
Interaction to Next Paint replaced First Input Delay as a Core Web Vital in March 2024, and it is a much harder metric to pass. FID only measured the wait before your handler started. INP measures the whole round trip, from input to the next painted frame, for every click, tap and key press during the visit, and reports close to the worst one.
This article is the profiling and yielding half of the story. If you want the wider order of operations (query budgets, rendering strategy, then assets), that lives in my full-stack web performance guide. Here we stay on the main thread.
What INP measures, and the thresholds
Google’s thresholds, from web.dev’s INP documentation, are assessed at the 75th percentile of page views, split by mobile and desktop:
| Rating | INP |
|---|---|
| Good | 200 ms or less |
| Needs improvement | Over 200 ms, up to 500 ms |
| Poor | Over 500 ms |
Within one page view the reported value is the slowest interaction, with one outlier ignored for every 50 interactions. Most pages see fewer than 50, so in practice you are optimising the worst click on the page, not the average. Hover, scroll and zoom are not measured, though a click or key press inside those gestures is.
Every interaction splits into three phases, and each phase has different causes:
| Phase | Runs from | Usually caused by | First fix to try |
|---|---|---|---|
| Input delay | Input until your first handler starts | Other tasks already on the main thread: hydration, third-party scripts, timers | Break up or defer the competing work |
| Processing duration | All event handlers for the interaction | Your own handler doing too much synchronously | Update the UI first, then yield before the rest |
| Presentation delay | Handlers finished until the frame is painted | Large DOM updates, expensive style and layout, forced reflows | Render less, avoid layout thrash |
Step 1: find the slow interaction in the field
Lab tools cannot guess which interaction your users find slow. Real-user data can. The web-vitals library’s attribution build gives you the target element, the three phase timings and, in Chromium, the Long Animation Frame entries that overlapped the interaction.
The Long Animation Frames API (LoAF), shipped in Chrome 123, is what makes this useful. A long animation frame is one where rendering was delayed by more than 50 ms, and each entry lists the scripts that ran for more than 5 ms in it, with source URL, function name and how they were invoked. That turns “INP is 480 ms” into “INP is 480 ms and most of it is a click listener in the consent manager”.
// Assumes web-vitals v5 and an endpoint at /rum that accepts JSON.
import { onINP } from 'web-vitals/attribution';
onINP(({ value, rating, attribution }) => {
const {
interactionTarget,
interactionType,
inputDelay,
processingDuration,
presentationDelay,
longAnimationFrameEntries = [],
} = attribution;
// The three slowest scripts across the overlapping long animation frames.
const scripts = longAnimationFrameEntries
.flatMap((frame) => frame.scripts)
.sort((a, b) => b.duration - a.duration)
.slice(0, 3)
.map((s) => ({
source: s.sourceURL,
fn: s.sourceFunctionName,
invoker: s.invoker,
ms: Math.round(s.duration),
}));
navigator.sendBeacon('/rum', JSON.stringify({
metric: 'INP',
value,
rating,
interactionTarget,
interactionType,
inputDelay,
processingDuration,
presentationDelay,
scripts,
}));
});
Group the results by interactionTarget and look at the worst targets first. Two cautions: LoAF is Chromium-only, so Firefox and Safari sessions will have phase timings but no script attribution, and the library matches LoAF entries to interactions by timestamp, so treat the script list as strong evidence rather than proof.
Step 2: reproduce it in the DevTools Performance panel
Once you know which element and which page, reproduce the interaction locally. The current Performance panel does most of the analysis for you:
- Live metrics. Open the panel and interact with the page. It records local INP and lists each interaction with its phases in the Interactions tab, so you can click the slow element and see the number immediately.
- Throttle the CPU realistically. A developer laptop hides INP problems. Use the calibrated CPU throttling presets to approximate a low- or mid-tier phone, and compare against CrUX field data if you have connected it.
- Record a trace and read the insights. The Insights sidebar includes an INP breakdown (phases for the slowest interaction), Forced reflow (JavaScript reading layout after writing to the DOM), Optimize DOM size and Third parties.
- Dim third parties. The checkbox greys out third-party events so you can see whether the long task is yours or a vendor’s.
The flame chart under the interaction tells you which phase is long. A long task that started before your click is input delay. A wide block under your event listener is processing. A long purple Layout or Recalculate Style after the handlers finish is presentation delay.
Step 3: fix the phase that dominates
Input delay: something else was running
If the main thread was busy when the user clicked, your handler had to queue. Typical sources are hydration of a large tree, tag manager payloads firing on load, polling timers and analytics batching. Fixes are mostly removals and deferrals: load non-essential third parties after the page is interactive, hydrate less (server components help here, and streaming with Suspense lets React hydrate sections independently), and split any of your own long startup tasks with the same yielding pattern described below.
Processing duration: your handler does too much
This is where yielding pays off. Any task over 50 ms is a long task, and while it runs the browser cannot paint. The pattern from web.dev’s guide to optimising long tasks is: do the work the user needs to see first, yield so the browser can paint it, then continue with the rest in chunks.
// Prefer scheduler.yield(); fall back to a macrotask where it is unsupported.
function yieldToMain() {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
let currentRun = 0;
filterButton.addEventListener('click', async () => {
const run = ++currentRun;
// 1. Immediate, cheap visual feedback.
filterButton.setAttribute('aria-busy', 'true');
status.textContent = 'Filtering…';
// 2. Let the browser paint that feedback before the heavy work.
await yieldToMain();
// 3. Process in slices, yielding whenever a slice exceeds its budget.
const matches = [];
let sliceStart = performance.now();
for (const item of items) {
if (item.tags.includes(selectedTag)) matches.push(item);
if (performance.now() - sliceStart > 10) {
await yieldToMain();
if (run !== currentRun) return; // a newer click superseded this one
sliceStart = performance.now();
}
}
renderResults(matches);
filterButton.removeAttribute('aria-busy');
status.textContent = `${matches.length} results`;
});
Two details matter. First, once you yield, other events can run in between, so you need a way to abandon stale work (the run counter above, or an AbortController). Second, do not yield after every function call. Yield after the work the user is waiting to see, and before work they are not.
Why scheduler.yield() rather than setTimeout? The continuation goes into a boosted queue and runs ahead of other tasks of the same priority, so third-party tasks queued in the meantime do not jump in front of the rest of your handler. setTimeout sends your continuation to the back of the queue.
In React, the equivalent tool is startTransition or useDeferredValue: keep the input’s own state update urgent and mark the expensive list re-render as a transition so React can interrupt it. Before reaching for either, check whether the render is expensive at all. The workflow in React performance: re-renders, memoisation and profiling usually finds one slow component rather than many.
Presentation delay: the frame is expensive to produce
If handlers finish quickly but the paint is still late, the cost is in style, layout and paint. Common causes:
- Too much DOM changing at once. Rendering 2,000 rows where 50 are visible. Virtualise long lists or paginate.
- Forced synchronous layout. Reading
offsetHeightorgetBoundingClientRect()after writing to the DOM in the same task forces the browser to lay out early, and repeatedly in a loop. Batch reads before writes. - Large style recalculations. Toggling a class on
<body>that invalidates styles across the whole page. Scope the change to the component. - Off-screen content.
content-visibility: autoon long sections lets the browser skip rendering work for content outside the viewport.
scheduler.yield() support in October 2026
Checked against MDN and caniuse in October 2026:
| Browser | scheduler.yield() |
|---|---|
| Chrome, Edge | Supported from 129 |
| Firefox | Supported from 142 |
| Safari (macOS and iOS) | Not supported |
MDN still lists it as limited availability rather than Baseline, which is why the feature-detected fallback above is not optional. The fallback still breaks up the long task; Safari users just lose the queue-jumping benefit.
One API to stop using: navigator.scheduling.isInputPending(). web.dev no longer recommends it, because it can return false after a user has interacted, and input is not the only reason to yield. Yield on a time budget instead.
An INP triage checklist
- Collect INP with attribution from real users, grouped by page template and interaction target.
- Pick the worst target on the highest-traffic template. Ignore the rest until it is fixed.
- Reproduce with calibrated CPU throttling and read the INP breakdown insight.
- Input delay dominant: find what was already running, defer or split it, check third parties.
- Processing dominant: paint feedback first, yield, slice the rest, guard against stale runs.
- Presentation dominant: reduce DOM churn, remove forced reflows, scope style changes.
- Ship, then compare field INP for that target over the following 28 days, which is the window CrUX reports on.
Questions people actually ask
- Why is my INP good in Lighthouse but poor in Search Console?
- A standard Lighthouse navigation run does not click anything, so it cannot measure INP. It reports Total Blocking Time as a lab proxy. Search Console uses CrUX field data from real interactions on real devices, which is the number that counts.
- Is a long task always an INP problem?
- No. A long task only hurts INP if it overlaps an interaction or delays the frame after one. The reverse also holds: an interaction can be slow with no single long task, through several medium tasks plus expensive rendering, which is why LoAF is more useful than the older Long Tasks API for INP work.
- Can I use scheduler.yield() in production when Safari does not support it?
- Yes, behind feature detection with a
setTimeoutfallback, as in the example above. Every browser gets the task broken up; Chromium and Firefox also get priority-preserving continuations.
Failing INP on your key templates?
I can trace your slowest interactions, attribute them to the scripts responsible and work with your team on fixes that hold up in field data.