A React onChange Handler Freezes the UI and Spikes INP to 480ms
You have a filter input or a "select all" checkbox whose onChange does real work — filtering ten thousand rows, recomputing totals, updating several pieces of state — and field INP for that interaction sits around 480ms, far past the 200ms "good" boundary. The page visibly stalls for a beat on every keystroke. This page resolves that specific scenario; the scheduling concepts behind it live in optimizing INP with scheduler.yield(), and the broader interactivity metric is covered in Core Web Vitals & Measurement.
Rapid Diagnosis in the Performance Panel
Confirm it is a processing-duration problem before changing code:
- Open DevTools Performance, enable
4xCPU throttling, record, and perform the interaction once. - Find the interaction in the Interactions track — its bar shows total INP. Click it.
- Read the breakdown: a small input delay plus a wide processing block means your handler is the long task. A wide block before the handler means an upstream task inflated input delay instead.
- In the flame chart, the long task will show your handler at the top with React's
commitRoot/render work beneath it. Any single task wider than50msis the offender.
A faster sanity check before the full profile: add a PerformanceObserver for longtask entries and log their duration during the interaction. If you see a single entry near 480ms attributed to your script, the handler is doing too much synchronously and the fix is structural, not micro-optimization. For replaying and ranking interactions across a session, use the workflow in profiling event handlers for INP.
The distinction that drives the fix is which phase is wide. A handler that genuinely runs for 480ms is a processing-duration problem and is solved by yielding, deferring, or offloading. A handler that itself runs in 40ms but reports 480ms INP is an input-delay problem — something else was already monopolizing the main thread when the click landed — and the fix belongs in that upstream task, not here. Always read the phase split before touching the handler.
Root Cause Analysis
1. Synchronous setState cascades. Calling several setStates that each trigger dependent effects, which set more state, chains multiple render passes into one task. React batches updates inside a handler, but cascading effect-driven updates re-run render and layout repeatedly within the same interaction. The telltale sign in the flame chart is several commitRoot blocks back to back inside one interaction rather than a single commit. Each pass also forces layout if any effect reads geometry (getBoundingClientRect, offsetHeight), compounding the cost.
2. Large list re-render. A single state change re-renders thousands of list children with no virtualization or memoization, so React's reconciliation and the browser's layout/paint dominate the processing phase. The cost scales with the number of mounted DOM nodes, not the number that changed, so even a one-character query that filters the list still pays to reconcile every row. This is the most common cause of the 480ms scenario in data-heavy tables.
3. Heavy derivation inside the handler. Filtering, sorting, or aggregating a large array directly in onChange runs that O(n) or O(n log n) work synchronously on every keystroke before React even commits. Because it runs inside the handler, it inflates processing duration directly, and because it runs on every keystroke it cannot be amortized — typing "report" pays the cost six times.
4. Third-party synchronous work. An analytics call, a validation library, or a feature-flag SDK invoked inline in the handler runs its own long synchronous block, adding to processing duration on every interaction. These are easy to miss because they look like a single innocuous function call; in the flame chart they appear as a wide block under a node-module path you did not write.
Step-by-Step Resolution
Fix 1 — Keep the input responsive with useTransition
Mark the expensive state update as non-urgent so the keystroke commits and paints immediately while the heavy re-render runs in an interruptible transition. This is the highest-leverage change for the large-list case.
import { useState, useTransition, useMemo } from 'react';
function Filter({ rows }) {
const [query, setQuery] = useState('');
const [deferredQuery, setDeferredQuery] = useState('');
const [isPending, startTransition] = useTransition();
function onChange(e) {
setQuery(e.target.value); // urgent: input paints now
startTransition(() => setDeferredQuery(e.target.value)); // non-urgent: heavy re-render
}
const visible = useMemo(
() => rows.filter((r) => r.name.includes(deferredQuery)),
[rows, deferredQuery]
);
// trade-off: useTransition shows stale results until the transition lands, so for a
// field that MUST reflect the latest value instantly (e.g. a password meter) it feels
// laggy — use it only when a brief stale paint is acceptable.
return <List items={visible} dim={isPending} />;
}
Expected outcome: input delay drops to near zero and the keystroke paints in one frame, moving processing out of the interaction's critical path — typically cuts INP from ~480ms to under 200ms on the large-list case.
Fix 2 — Yield between batches in the derivation
When the heavy work is an imperative loop rather than a render (building an index, transforming rows), split it with the await-yield pattern so no chunk exceeds the 50ms budget.
async function buildIndex(rows, signal) {
const index = new Map();
let deadline = performance.now() + 50; // 50ms long-task budget
for (let i = 0; i < rows.length; i++) {
index.set(rows[i].id, normalize(rows[i]));
if (performance.now() >= deadline) {
await (window.scheduler?.yield?.() ?? new Promise((r) => setTimeout(r, 0)));
if (signal.aborted) return null; // a newer keystroke superseded this run
deadline = performance.now() + 50;
}
}
return index;
// trade-off: yielding makes the index arrive a few frames later, so reads must
// tolerate a transient null/partial result — guard consumers accordingly.
}
Expected outcome: a single 220ms derivation becomes five ~44ms chunks; queued input runs between them, removing the long task and reducing the processing phase by ~200ms.
Fix 3 — Defer non-urgent third-party work out of the handler
Move analytics, logging, and flag evaluation off the interaction's critical path with scheduler.postTask at background priority (or requestIdleCallback), so they no longer add to processing duration.
function onClick() {
applyFilter(); // urgent, stays in the handler
const send = () => analytics.track('filter_applied', { query });
if (window.scheduler?.postTask) {
window.scheduler.postTask(send, { priority: 'background' });
} else {
setTimeout(send, 0);
}
// trade-off: background-scheduled analytics can be dropped if the user navigates away
// before idle — for must-deliver events use navigator.sendBeacon synchronously instead.
}
Expected outcome: removes the third-party synchronous block from the interaction, trimming processing duration by however long that SDK ran (commonly 30–80ms).
Fix 4 — Move CPU-bound work to a worker
If the derivation is genuinely heavy (parsing, large-array math, fuzzy search), yielding only makes it interruptible — it does not make it cheap. Offload it entirely.
import { wrap } from 'comlink';
const search = wrap(new Worker(new URL('./search.worker.js', import.meta.url), { type: 'module' }));
async function onChange(e) {
const results = await search.filter(e.target.value); // runs off the main thread
startTransition(() => setResults(results));
// trade-off: the worker round-trip adds postMessage serialization latency, so for
// small/cheap derivations a main-thread useMemo is faster — reserve workers for jobs
// that exceed ~50ms of pure computation.
}
Expected outcome: the main-thread processing phase collapses to the message round-trip (single-digit ms), holding INP well under 200ms even for expensive payloads. The full setup is in offloading work to web workers with Comlink.
Verification
Re-record the interaction under 4x throttling: the previously wide processing block should now be either a single small commit (Fix 1/4) or a series of sub-50ms chunks (Fix 2), with no task crossing the long-task threshold. In CI, assert Total Blocking Time as the lab proxy:
// lighthouserc.js — fail the build if main-thread blocking regresses
module.exports = {
ci: { assert: { assertions: {
'total-blocking-time': ['error', { maxNumericValue: 200 }],
} } },
};
// trade-off: TBT is measured at load, so a slow post-load interaction can pass CI while
// field INP stays red — confirm the win in RUM at the p75, not just in the lab.
Finally, watch field INP at the 75th percentile for that interaction in your RUM dashboard for a few days; the lab gate prevents regressions merging, and the field number confirms the fix on real devices. For the same techniques applied at app scale, see improving INP for complex single page applications.
Related
- Optimizing INP with scheduler.yield() — the scheduling APIs and yield-point patterns behind these fixes.
- Profiling event handlers for INP — locating and ranking the slow interaction in the Performance panel.
- Offloading work to web workers with Comlink — moving genuinely heavy derivation off the main thread.
- Improving INP for complex single page applications — applying these patterns across router transitions and hydration.