LogixLoops
Because one state update can synchronously re-render a large tree, and that work blocks the main thread before the browser gets a chance to paint. The click is registered immediately. The frame showing its result arrives 400ms later. First Input Delay never caught this, it only measured the first interaction, and only the wait before the handler ran. INP measures every interaction, all the way through to the pixels.
The target: 200ms or less at the 75th percentile of real users. Above 500ms is a failure.
Local profiling on a fast laptop will not reproduce this. Instrument production and let real devices tell you.
import { onINP } from "web-vitals/attribution";
onINP(({ value, attribution }) => {
// attribution names the element and splits the timing into
// input delay / processing / presentation delay, which of the three
// dominates is what tells you which fix below applies.
sendToAnalytics({
value,
target: attribution.interactionTarget,
inputDelay: attribution.inputDelay,
processingDuration: attribution.processingDuration,
presentationDelay: attribution.presentationDelay,
});
});
Read the split before changing anything. High input delay means the main thread was already busy when the user clicked, the problem is elsewhere on the page, often a third-party script. High processing duration means your handler and the render it triggers are the problem. High presentation delay usually means layout or paint cost, not JavaScript.
The cheapest win is letting the browser paint before you do the heavy work. Update what the user can see, hand the thread back, then continue.
function onFilterChange(next) {
setFilterLabel(next); // cheap: paint the new state now
scheduler.yield?.() ?? new Promise((r) => setTimeout(r, 0));
// ...then run the expensive recompute after the frame
}
A visible response within 100ms and the full result at 400ms scores, and feels, dramatically better than both arriving together at 400ms.
React 18's concurrent features exist for exactly this shape of problem. The typed character must land immediately; the filtered list of 5,000 rows can arrive a frame or two later.
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
// Input stays responsive; the expensive list tracks the deferred value.
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ResultsList query={deferredQuery} />
useTransition does the same job when you control the update site rather
than the value.
If a list can exceed a few hundred rows, virtualise it. No amount of memoisation makes rendering 5,000 DOM nodes fast, and every interaction that touches that list pays the cost again. This is the single highest-impact change on data-heavy internal tools, which is where we see the worst INP numbers in practice.
Analytics, chat widgets, tag managers and session recorders run on the same
single main thread as your application. A tag manager firing on every click is
a very common cause of high input delay, a component you did not write
making a component you did feel slow. Load them with async, defer anything
non-essential past first interaction, and re-measure with each one disabled to
find out what you are actually paying for.
Wrapping every component in React.memo. It adds a comparison cost to every
render and fixes nothing if the underlying problem is one very expensive
subtree or a 5,000-row list. Measure which interaction is slow, then fix that
one.
scheduler.yield().Join our engineering newsletter to get deep-dives like this delivered straight to your inbox every month.