Interaction to Next Paint (INP) has been a Core Web Vital since March 2024, when it replaced First Input Delay. It's also the one teams find hardest to fix, because the usual advice ("reduce JavaScript", "use memo") is aimed at the wrong thing half the time.
INP isn't one number you can attack directly. It's the latency of a real user's slowest interactions, and each interaction's latency is made of three phases that have completely different causes. Fix the wrong phase and the score doesn't move.
So the order matters: find the interaction, find the slow phase, then pick the fix. Here's how we do that in React apps.
What INP actually measures
From web.dev's INP guide, an interaction's latency has three parts:
- Input delay: from the user's tap or click until your event handlers start running. Long if the main thread is busy with something else at that moment.
- Processing duration: how long your event handlers take to run.
- Presentation delay: from the end of your handlers until the browser paints the next frame. Long if the update triggers heavy rendering, layout or style work.
The thresholds, at the 75th percentile of page loads:
| INP | Rating |
|---|---|
| 200 ms or less | Good |
| 200–500 ms | Needs improvement |
| Over 500 ms | Poor |
A page with a 520 ms INP might be 90 ms of input delay, 310 ms of processing and 120 ms of presentation. Memoizing components would barely touch that. Moving work out of the click handler would.
Step 1: Measure in the field, with attribution
Lab tools can tell you an interaction can be slow. Only field data tells you which interactions real users hit, on which devices. The web-vitals library's attribution build gives you the phase breakdown and the element that was interacted with:
// vitals.js
import { onINP } from "web-vitals/attribution";
onINP(({ value, rating, attribution }) => {
const body = JSON.stringify({
metric: "INP",
value: Math.round(value),
rating,
target: attribution.interactionTarget, // CSS selector of the element
type: attribution.interactionType, // "pointer" or "keyboard"
inputDelay: Math.round(attribution.inputDelay),
processing: Math.round(attribution.processingDuration),
presentation: Math.round(attribution.presentationDelay),
loadState: attribution.loadState, // was the page still loading?
page: location.pathname,
});
// sendBeacon survives the page being closed
navigator.sendBeacon("/api/vitals", body);
});
Store those rows anywhere you can query. After a few days, group by target and look at the worst ones:
SELECT target,
COUNT(*) AS samples,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) AS p75_inp,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY input_delay) AS p75_input,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY processing) AS p75_processing,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY presentation) AS p75_presentation
FROM vitals
WHERE metric = 'INP' AND created_at > now() - interval '7 days'
GROUP BY target
ORDER BY p75_inp DESC
LIMIT 20;
The top of that list is your worklist, and the largest of the three phase columns tells you which section below to read.
Step 2a: Input delay is high → something else owns the main thread
The user clicked, but your handler had to wait. Common causes in React apps:
-
Hydration. On server-rendered pages, early clicks land while React is still hydrating. If
loadStatein your data is mostly"dom-interactive"or"dom-content-loaded", this is it. - Third-party scripts. Chat widgets, tag managers, A/B testing and session recorders that run long tasks on load or on a timer.
- Your own timers and polling that do heavy work on the main thread.
What helps:
- Load third-party scripts after the page is interactive, or move them off the main thread where the vendor supports it.
- Split large client bundles so less JavaScript runs during hydration. In Next.js App Router or other React Server Components setups, keep components on the server unless they need interactivity.
- Break up your own long tasks (see the yield helper in the next section). web.dev defines a long task as anything over 50 ms. Every one of them is a window where a click has to wait.
Step 2b: Processing duration is high → your handler does too much
This is the most common case we see, and the easiest to fix. The handler does everything synchronously: updates the filter state, recalculates a big list, writes to localStorage, fires analytics, all before the browser is allowed to paint.
The principle: do the minimum needed for visual feedback, paint, then do the rest.
In React, that usually means marking the expensive update as non-urgent with startTransition:
import { useState, useTransition } from "react";
function ProductFilters({ products }) {
const [query, setQuery] = useState("");
const [visible, setVisible] = useState(products);
const [isPending, startTransition] = useTransition();
function onChange(e) {
const next = e.target.value;
setQuery(next); // urgent: the input must update now
startTransition(() => { // non-urgent: React can interrupt this
setVisible(filterProducts(products, next));
});
}
return (
<>
<input value={query} onChange={onChange} />
{isPending && <span className="spinner" aria-live="polite">Updating…</span>}
<ProductList items={visible} />
</>
);
}
For work that isn't a React state update (analytics, storage, logging), yield to the main thread so the browser can paint first. scheduler.yield() is built for this. It's supported in Chromium-based browsers and Firefox, but not Safari yet, so keep a fallback:
// yield.js
export function yieldToMain() {
if (globalThis.scheduler?.yield) return scheduler.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function onAddToCart(item) {
setCart((c) => [...c, item]); // visible feedback first
await yieldToMain(); // let the browser paint
saveCartToStorage(); // then the slower, invisible work
track("add_to_cart", { id: item.id });
}
Two more things that hide in handlers:
-
Large synchronous
JSON.stringify/localStorage.setItemcalls. Fine for small objects, slow for a whole cart or form state on every keystroke. Debounce them. - Context updates that re-render half the app. If a click updates a context value that dozens of components read, the handler's cost includes every one of those renders. Split the context or move the state down.
Step 2c: Presentation delay is high → the update is expensive to render
Your handler was quick, but the result is a lot of DOM to update, lay out and paint.
- Long lists. Rendering 2,000 table rows after a filter is slow no matter how fast the filter is. Virtualize with something like TanStack Virtual or react-window, so only visible rows exist in the DOM.
-
Huge DOM in general. Big pages make every style and layout recalculation slower. For long content below the fold, the CSS property
content-visibility: autolets the browser skip rendering work for off-screen sections. -
Layout thrashing. Reading layout (
offsetHeight,getBoundingClientRect) right after writing styles forces the browser to lay out synchronously. Batch reads before writes. - Expensive CSS on elements that change often, such as large blurred shadows or filters on animated elements.
Step 3: Reproduce in the lab, then confirm in the field
Once you know the interaction and the phase, reproduce it in Chrome DevTools:
- Open the Performance panel and turn on CPU throttling (4x or 6x) to approximate a mid-range phone.
- Record while performing the exact interaction from your field data.
- Look at the Interactions track. The interaction's bar is split into input delay, processing and presentation, and the main-thread flame chart below shows what ran in each.
Fix, re-record, compare. Then wait for the field data to catch up. Your own /api/vitals data will show the change within days. Google's Chrome UX Report uses a rolling 28-day window, so Search Console takes about a month to reflect it.
A quick checklist
- [ ]
web-vitals/attributionis reporting INP with phase breakdown and target - [ ] You have the top 10 worst interactions by p75, from real users
- [ ] For each one, you know which of the three phases dominates
- [ ] Input delay → third parties deferred, hydration work reduced, long tasks split
- [ ] Processing → visual update first,
startTransitionoryieldToMain()for the rest - [ ] Presentation → long lists virtualized, DOM size checked, no layout thrashing
- [ ] Fix verified in DevTools with CPU throttling, then in field data
INP rewards a specific kind of discipline: give the user visual feedback immediately, and push everything else just after the paint. Once a team has that habit, most new features arrive with a good INP by default.
We set performance targets like these before a page is designed, as part of how we approach custom web development. It's much cheaper than finding them in Search Console six months after launch.
Your turn: which phase was the culprit in your worst INP interaction? In our experience it's processing more often than not, but we'd like to hear about the odd ones, especially third-party scripts that only misbehave on certain pages.
From the team at Redlio Labs, where we build and run web applications.
Top comments (0)