You click a button, nothing happens, and then half a second later the whole screen jumps. That little pause is exactly what Interaction to Next Paint, or INP, measures. It’s the Core Web Vital that most React apps struggle with, and the good news is that the fixes are usually small once you know where to look.
In this guide we’ll show how to improve INP in React apps: measure it in a real React app, find the slow interactions, and fix them with tools React and the browser already give you. If you’re new to the metrics themselves, it’s worth skimming our explainer on what Core Web Vitals actually measure first.
What is INP and why does it matter for React apps?
INP watches every click, tap and key press during a visit and reports roughly the slowest one. Each interaction has three parts. There’s the input delay while the main thread is busy with something else, the processing time while your event handlers run, and the presentation delay while the browser recalculates styles, lays out the page and paints the next frame.
According to web.dev’s INP guide, a score of 200 milliseconds or less is good and anything above 500 milliseconds is poor. React apps tend to suffer because a single state change can re-render a big tree of components, and all of that happens on the same main thread that’s trying to respond to the user.
How do you measure INP in a React app?
Start with real user data, since lab tools can’t click around like people do. The web-vitals library from the Chrome team gives you INP in a few lines, along with attribution that tells you which element was involved.
npm install web-vitals
// src/reportVitals.js
import { onINP } from 'web-vitals/attribution';
onINP(({ value, rating, attribution }) => {
// Send this to your analytics endpoint instead of the console
console.log('INP', Math.round(value), rating, {
target: attribution.interactionTarget,
inputDelay: attribution.inputDelay,
processing: attribution.processingDuration,
presentation: attribution.presentationDelay,
});
});
Import that file once in your entry point and you’ll see which part of the interaction is slow. A big input delay usually means something else was hogging the thread. Long processing points at your handlers or renders, and a large presentation delay hints at heavy layout or a huge DOM.
For local debugging, open the Performance panel in Chrome DevTools, record while you click the slow button, and look for long tasks with red corners. The React Developer Tools Profiler is handy too, because it shows which components re-rendered and how long each one took.
Why does one click feel slow?
In most React apps it comes down to a handful of patterns. Here are the ones I run into most often.
- A state update high up in the tree re-renders hundreds of components that didn’t need to change.
- A filter or sort over a large list runs synchronously inside the click or input handler.
- Analytics, logging or third party scripts run work in the same handler before React can paint.
- A long list renders thousands of DOM nodes, so every update means a lot of layout work.
Once you know which pattern you’re dealing with, the fix is usually one of the four techniques below.
How does useTransition help INP?
React 18 introduced transitions, and they’re still the cleanest fix for slow state updates. You mark an update as non urgent and React keeps the urgent part, like the text in an input, responsive while the expensive render happens in the background. If the user types again, React can throw away the stale render instead of finishing it.
import { useState, useTransition } from 'react';
function ProductSearch({ products }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState(products);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
const value = e.target.value;
setQuery(value); // urgent, the input updates right away
startTransition(() => {
// non urgent, React can interrupt this render
setResults(
products.filter((p) =>
p.name.toLowerCase().includes(value.toLowerCase())
)
);
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <p>Updating results...</p>}
<ProductList items={results} />
</>
);
}
If you don’t control where the state is set, for example when the value comes in as a prop, useDeferredValue does a similar job by letting a slow child lag a little behind the fast one.
How do you break up long JavaScript tasks?
Transitions only help with React renders. When your own code does heavy work, like processing a big array after a click, you need to hand control back to the browser now and then so it can respond to input and paint. Chrome’s guidance on breaking up long tasks recommends yielding roughly every 50 milliseconds, since that’s the point where a task officially becomes a long task.
function yieldToMain() {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
// Fallback for browsers without the Scheduler API
return new Promise((resolve) => setTimeout(resolve, 0));
}
export async function processRows(rows, handleRow) {
let deadline = performance.now() + 50;
for (const row of rows) {
handleRow(row);
if (performance.now() >= deadline) {
await yieldToMain();
deadline = performance.now() + 50;
}
}
}
The feature check matters. According to Can I use, scheduler.yield works in Chrome and Edge 129 and later and in Firefox 142 and later, but Safari doesn’t support it yet. A neat trick is to update the UI first, yield, and then do the non visual work like analytics, so the user sees feedback straight away.
How do you stop unnecessary re-renders?
Sometimes the real problem is that a click re-renders far more than it should. Keep state as close as possible to the components that use it, split big contexts into smaller ones, and wrap genuinely expensive children in memo. We covered these patterns in more depth in our React performance optimization tips, and if your project uses the React Compiler, a lot of the manual memoisation gets handled for you.
import { memo } from 'react';
const ProductList = memo(function ProductList({ items }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
});
Memoisation only helps when props are stable, so watch out for new objects and inline functions passed down on every render.
How do you keep the DOM small?
Presentation delay grows with the size of your page. If you render a table with five thousand rows, every update forces the browser to deal with all of them. Virtualise long lists with a library like TanStack Virtual or react-window so only the visible rows exist in the DOM. For sections below the fold, the CSS property content-visibility set to auto lets the browser skip rendering work until they scroll into view.
Shipping less JavaScript helps too, because less code means less parsing and fewer long tasks competing with user input. Our guide on code splitting and shrinking your JavaScript payload walks through that side of things.
A quick INP checklist
- Measure real users with the web-vitals attribution build before guessing.
- Wrap expensive state updates in startTransition or use useDeferredValue.
- Yield to the main thread during heavy loops, with a setTimeout fallback.
- Move state down and memoise expensive children to cut re-renders.
- Virtualise long lists and keep the DOM lean.
- Defer analytics and other non visual work until after the next paint.
Conclusion
INP feels intimidating because it measures the whole interaction, but in React apps the culprit is nearly always too much work on the main thread at the wrong moment. Measure first, fix the slowest interaction, and measure again. A couple of transitions and one well placed yield can easily take a sluggish button under that 200 millisecond mark, and your users will notice the difference even if they never hear the word INP.
Frequently asked questions
What is a good INP score?
Google treats an INP of 200 milliseconds or less as good, anything up to 500 milliseconds as needing improvement, and anything above 500 milliseconds as poor. The score is judged at the 75th percentile of real page visits, so most of your users need a snappy experience, not just the ones on fast laptops.
Did INP replace FID?
Yes. Interaction to Next Paint became a Core Web Vital in March 2024 and replaced First Input Delay. FID only measured the delay before the first interaction started processing, while INP looks at the full latency of interactions across the whole visit.
Does useTransition make my code run faster?
Not exactly. It doesn’t speed up the work itself, it marks a state update as non urgent so React can keep urgent updates like typing responsive and interrupt the slow render if the user does something new. The result feels faster because the screen reacts immediately.
Is scheduler.yield supported in every browser?
No. It works in Chrome and Edge 129 and later and Firefox 142 and later, but Safari doesn’t support it yet. Always feature detect it and fall back to a setTimeout based yield so your code still runs everywhere.
Why is my INP good in Lighthouse but bad in Search Console?
A standard Lighthouse run loads the page without clicking anything, so it can’t measure INP at all and uses Total Blocking Time as a rough lab stand in. Search Console uses field data from real Chrome users on real devices, which is why it often shows problems your own machine hides.





