A filter that takes a moment to respond can make an otherwise polished React app feel broken. The button registers the click, but the selected state appears late, the list freezes, or the interface ignores the next input while it catches up.
To improve Interaction to Next Paint (INP) in React, start with real-user data, identify the slow interaction, and trace its work in the browser. Then fix the cause: unnecessary component rendering, expensive synchronous JavaScript, layout and paint work, or a combination. Do not begin by adding memo to every component.
This guide uses React 19.3.1 and browser APIs available in modern Chromium-based browsers. The examples are illustrative implementation patterns, not benchmark results or a claim that a particular application was tested. The React Compiler is optional; manual profiling still matters.
TL;DR: diagnose before optimizing#
- Find the affected page and interaction in field INP data, segmented by mobile and desktop.
- Reproduce the gesture in Chrome DevTools with a realistic CPU profile.
- Separate input delay, event-handler processing and presentation delay.
- Use React DevTools Profiler to see which components rendered and how much React rendering contributed.
- Apply the smallest relevant fix, then compare the same interaction and confirm the field metric improves.
INP is not a React-only metric. It measures the time from a user interaction until the browser presents the next frame. React rendering can contribute, but so can unrelated JavaScript, style recalculation, layout, paint, third-party scripts and work that was already occupying the main thread.
What INP measures#
Interaction to Next Paint is a Core Web Vital that assesses responsiveness across a page visit. It observes qualifying click, tap and keyboard interactions and reports a representative high-latency interaction. The metric is evaluated at the 75th percentile of page visits, separately for mobile and desktop.
The current web.dev guidance defines these thresholds:
| INP at the 75th percentile | Assessment |
|---|---|
| 200 ms or less | Good |
| Above 200 ms and up to 500 ms | Needs improvement |
| Above 500 ms | Poor |
These are assessment thresholds, not a promise that every interaction below 200 ms feels identical on every device. Use the field distribution to understand the experience of real visitors. A lab trace helps explain a specific interaction; it does not replace field measurement.
An interaction's latency has three useful parts:
- Input delay: time between the user's gesture and the start of the event callbacks. A busy main thread can delay the callbacks before your click handler runs.
- Processing duration: time spent running the event handlers associated with the gesture.
- Presentation delay: time after the handlers finish until the browser can render and present the next frame.
This breakdown changes how you debug. If input delay dominates, inspect tasks that ran before the event. If processing dominates, inspect handlers and synchronous work they trigger. If presentation dominates, inspect React rendering, style, layout and paint after the handlers.
Source: web.dev: Interaction to Next Paint.
Find the slow interaction in field data first#
Start with the users who experience the problem, not a development machine that may be substantially faster than theirs.
Use a Real User Monitoring (RUM) setup or the Chrome UX Report data available through your performance tooling. Look beyond the page-level INP value. You need to know which URL or route is affected, whether the issue is concentrated on mobile, and which interaction is responsible. Useful context includes the interaction target, event type, timing breakdown and whether the slow visit happened during or after page load.
If you instrument your own RUM pipeline, the web-vitals library provides an INP measurement and attribution build. Send measurements with enough context to group them by route, device class and interaction target. Avoid collecting raw user-entered content or other sensitive values just to label an interaction. A stable selector or component-level identifier is usually more useful than the text inside a field.
A field report such as “the orders page has poor INP on mobile” is a starting point, not a diagnosis. Narrow it to “changing the status filter on the orders page” or “opening the account menu after initial load.” Then reproduce that exact gesture.
Field data can also reveal issues that local testing misses: a third-party script, a slow device, a long-running task after navigation, or a page state that your test account does not contain. If field data is unavailable, use a reproducible user report as a temporary lead and mark the diagnosis as provisional.
Reproduce and profile the interaction#
1. Record the browser trace#
Open the affected route in Chrome, open DevTools, and select Performance. Use the live metrics view to observe local INP while interacting. Start a runtime recording, perform the exact gesture once, and stop the recording. Repeat with the relevant page state and CPU throttling that approximates the field environment.
Find the Interactions track. Select the slow interaction and inspect its input delay, processing time and presentation delay. Then inspect the Main track around the same time. Look for long JavaScript tasks, repeated event handlers, style recalculation, layout and paint. The flame chart and Bottom-up view help identify which functions account for the work.
Do not assume a long task that happens near the click is necessarily caused by the click. Check the call stack and timing boundaries. A task that starts before the user gesture can increase input delay even if it has nothing to do with the target component.
Chrome DevTools documents the Interactions track, the timing breakdown and the runtime recording workflow in its Performance panel reference.
2. Inspect React rendering separately#
The React Developer Tools Profiler answers a narrower question: which React components rendered for this update, and how much render time did the profiled tree consume?
Open the Profiler tab, start recording, perform the interaction, and inspect the resulting commit. Look for a small state change causing a large part of the page to render. Check whether expensive children rendered even though their relevant inputs did not change. Also inspect Effects that update state after a render; chains of Effects can create additional commits.
For a focused measurement in code, wrap the relevant subtree in React's <Profiler>:
import { Profiler, type ProfilerOnRenderCallback } from 'react';
const onRender: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) => {
// Send sampled, non-sensitive diagnostics to your dev logger.
console.debug('React commit', {
id, phase, actualDuration, baseDuration, startTime, commitTime,
});
};
export function OrdersPage() {
return (
<Profiler id="orders-results" onRender={onRender}>
<OrdersResults />
</Profiler>
);
}
actualDuration describes the time spent rendering the profiled tree for that update. baseDuration estimates the cost of rendering the entire subtree without optimizations. The Profiler adds overhead and is disabled in ordinary production builds by default; use the React DevTools Profiler for interactive investigation, or a profiling-enabled build when you need production measurements. Do not treat the callback's render duration as the full INP value: it does not include all browser work or time before React starts rendering.
See the React Profiler reference.
Fix the cause, not the symptom#
The following table maps common trace patterns to the first fix worth testing. It is a diagnostic guide, not a claim that every symptom has only one cause.
| Symptom in the trace or UI | Likely area to inspect | First fix to test |
|---|---|---|
| Typing or toggling rerenders an entire page | State is too high in the tree | Move transient state closer to the component that owns it |
| A click updates a large results panel synchronously | Urgent and non-urgent updates are coupled | Mark the expensive result update as a Transition |
| Search input feels blocked by filtering | Input and result rendering share one update priority | Defer the value used by the expensive results subtree |
| Scrolling a large table is sluggish | Too many DOM nodes and row renders | Virtualize the visible rows |
| A click triggers parsing, sorting or transformation work | CPU-heavy synchronous JavaScript | Move pure computation to a Web Worker |
| React render time is high for unchanged props | Repeated expensive component work | Memoize the measured subtree, or evaluate React Compiler |
| Browser trace shows layout or paint after React work | DOM size, layout or visual effects | Reduce DOM/layout work and avoid forced synchronous layout |
1. Render less: localize state and split components#
A common source of unnecessary work is placing short-lived state too high in the component tree. If typing in a search box updates state owned by the page shell, every descendant may be asked to render again.
Move the state to the smallest component that needs it. Keep the expensive results panel separate from unrelated controls. This improves the component boundary before introducing memoization.
Before:
import { useState } from 'react';
function OrdersPage({ orders }: { orders: Order[] }) {
const [query, setQuery] = useState('');
return (
<main>
<Header />
<SearchBox value={query} onChange={setQuery} />
<OrdersTable orders={orders} query={query} />
<BillingSidebar />
</main>
);
}
After:
import { useState } from 'react';
function OrdersPage({ orders }: { orders: Order[] }) {
return (
<main>
<Header />
<OrdersWorkspace orders={orders} />
<BillingSidebar />
</main>
);
}
function OrdersWorkspace({ orders }: { orders: Order[] }) {
const [query, setQuery] = useState('');
return (
<section>
<SearchBox value={query} onChange={setQuery} />
<OrdersTable orders={orders} query={query} />
</section>
);
}
The state now belongs to the workspace that needs it. This does not guarantee that the table is cheap, but unrelated page regions no longer need to be part of the same update path. Keep component boundaries aligned with state ownership and actual UI responsibilities.
2. Keep urgent input responsive with Transitions#
A filter selection often has two consequences: the control must reflect the user's choice immediately, while a large result panel can update at a lower priority. React Transitions let you mark the latter update as non-urgent so React can interrupt background rendering to handle more urgent work.
import { useState, useTransition } from 'react';
function StatusFilter() {
const [status, setStatus] = useState('all');
const [visibleStatus, setVisibleStatus] = useState('all');
const [isPending, startTransition] = useTransition();
function handleChange(nextStatus: string) {
setStatus(nextStatus); // Update the control urgently.
startTransition(() => {
setVisibleStatus(nextStatus); // Update expensive results non-urgently.
});
}
return (
<>
<select value={status} onChange={e => handleChange(e.target.value)}>
<option value="all">All</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
{isPending && <span aria-live="polite">Updating results…</span>}
<OrdersTable status={visibleStatus} />
</>
);
}
Transitions change scheduling priority; they do not make the underlying calculation faster. Keep controlled text-input updates urgent—React does not allow a Transition to control a text input. For asynchronous actions, state updates after an await may need another startTransition wrapper. See useTransition.
3. Defer expensive results while keeping input immediate#
When a component receives a value from a parent and you cannot directly wrap the parent's setter, useDeferredValue can let an expensive subtree lag behind the immediate value. The input remains controlled by the current query; the results use the deferred query.
import { useDeferredValue, useState } from 'react';
function ProductSearch({ products }: { products: Product[] }) {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<>
<input
value={query}
onChange={event => setQuery(event.target.value)}
aria-label="Search products"
/>
<ProductResults products={products} query={deferredQuery} />
</>
);
}
useDeferredValue does not impose a fixed delay and does not prevent network requests. It allows React to prioritize the current update and attempt the deferred render in the background. If the results subtree is not expensive, deferring it adds complexity without a meaningful benefit. Read the useDeferredValue reference.
4. Virtualize long lists#
Rendering thousands of rows creates work for React and the browser. It also creates a large DOM that can increase style, layout and paint costs. Virtualization renders only the rows around the visible viewport and reuses a small window as the user scrolls.
Use a maintained virtualizer when the list has variable row heights, accessibility requirements, sticky headers or complex grid behavior. The simplified example below demonstrates the fixed-height principle; it intentionally omits keyboard navigation, dynamic measurements and scroll restoration, so do not treat it as a complete table implementation.
import { useState } from 'react';
function VirtualList({ items }: { items: Item[] }) {
const rowHeight = 40;
const viewportHeight = 400;
const [scrollTop, setScrollTop] = useState(0);
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - 3);
const count = Math.ceil(viewportHeight / rowHeight) + 6;
const visible = items.slice(start, start + count);
return (
<div
style={{ height: viewportHeight, overflowY: 'auto' }}
onScroll={event => setScrollTop(event.currentTarget.scrollTop)}
>
<div style={{ height: items.length * rowHeight, position: 'relative' }}>
{visible.map((item, index) => {
const itemIndex = start + index;
return (
<div
key={item.id}
style={{
position: 'absolute',
top: itemIndex * rowHeight,
height: rowHeight,
width: '100%',
}}
>
{item.label}
</div>
);
})}
</div>
</div>
);
}
For production, validate focus behavior, screen-reader semantics, row measurement and overscan. A virtualized list can also make browser find-in-page and selecting content behave differently; decide whether those trade-offs fit the interface.
5. Move CPU-heavy work off the main thread#
Transitions and deferred values help React schedule rendering. They do not move arbitrary JavaScript computation to another thread. If a click handler parses a large file, transforms a dataset or performs expensive search over a large collection, move pure computation to a Web Worker where practical.
src/workers/search.worker.ts:
self.addEventListener('message', (event: MessageEvent<{ items: Item[]; query: string }>) => {
const { items, query } = event.data;
const normalized = query.toLowerCase();
const matches = items.filter(item =>
item.label.toLowerCase().includes(normalized)
);
self.postMessage(matches);
});
src/search.ts:
const searchWorker = new Worker(
new URL('./workers/search.worker.ts', import.meta.url),
{ type: 'module' }
);
export function searchInWorker(items: Item[], query: string) {
return new Promise<Item[]>(resolve => {
const onMessage = (event: MessageEvent<Item[]>) => {
searchWorker.removeEventListener('message', onMessage);
resolve(event.data);
};
searchWorker.addEventListener('message', onMessage);
searchWorker.postMessage({ items, query });
});
}
This is a minimal communication example, not a complete worker lifecycle abstraction. A real implementation should handle worker errors, cancellation or stale responses, termination, and large payload costs. Workers cannot access the DOM. They are useful for CPU-bound work that can operate on data and return a result. Structured cloning or transferring data has a cost, so measure the whole path rather than assuming a worker always wins. See web.dev's off-main-thread guide.
6. Memoize only when profiling justifies it#
memo, useMemo and useCallback can reduce repeated work, but they also add comparison, dependency and maintenance costs. Start by removing unnecessary state updates, localizing state, simplifying rendering and eliminating expensive work. Then use the Profiler to identify components that repeatedly render with unchanged inputs and have meaningful render cost.
React 19.3 supports the stable React Compiler, which can automatically memoize components, values and functions when it can safely analyze them. It is an optional build-time tool, not an automatic feature of every React 19 app. Check your build-tool integration, compiler diagnostics and rollout strategy before enabling it. With the compiler enabled, manual memoization is usually less necessary, but existing useMemo and useCallback calls should be evaluated carefully rather than removed wholesale.
References: React Compiler introduction, React memo.
Prevent regressions with monitoring and budgets#
A local trace tells you whether a change improved the reproduced interaction. Field monitoring tells you whether real users experienced the improvement across devices, routes and page states. Keep both in the workflow.
For each high-value interaction, define an owner and a reviewable budget. A useful budget is not simply “React render under X milliseconds”; INP includes browser work outside React. Instead, set a field INP objective aligned with the Core Web Vitals assessment threshold, and track the interaction-level causes that your team can act on. Segment by mobile and desktop and watch the 75th percentile over a stable reporting window.
Add performance checks to release review:
- Monitor INP by route and device segment, and alert on sustained regressions rather than one noisy visit.
- Keep a small set of repeatable interaction scenarios for filters, menus, search, forms and large tables.
- Record a browser trace before and after a substantial interaction change.
- Re-check third-party scripts and analytics handlers; instrumentation itself can create main-thread work.
- Use production-like builds and realistic data. Development mode and synthetic data can distort the result.
- Treat a good page-level score as a signal, not proof that every important interaction is responsive.
Do not invent a target for a component's render duration without measuring the application and its devices. Establish a baseline first, then set an engineering budget that reflects your actual user experience.
What to do next#
Slow React interactions are rarely fixed by one universal hook. Field data identifies the experience to investigate; the browser trace shows where time is spent; the React Profiler tells you how much of that work belongs to rendering. Apply the narrowest fix, then verify it in both the lab and the field.
Use the React Production-Readiness Checklist to review performance alongside the other concerns that affect a production React application. For the wider engineering context behind apparently small UI features, read Frontend Is Easy… Until You Actually Build Something.
References#
- Interaction to Next Paint (INP) — web.dev
- Optimize INP — web.dev
- Performance features reference — Chrome DevTools
- React Profiler
- useTransition
- useDeferredValue
- React Compiler
- React memo
- Use web workers to run JavaScript off the browser's main thread — web.dev



