React Performance Checklist for Production Apps
A practical, measurement-first checklist for making React apps fast: render cost, list virtualisation, bundle budgets, Suspense boundaries and the profiling workflow behind each fix.
Quick answer
Fix React performance in this order: measure with the Profiler and a real device, cut render work (stable props, memo on expensive subtrees, colocated state), then cut bytes (route-level code splitting, no barrel imports, tree-shakeable libraries), then cut waterfalls (parallel data loading and Suspense boundaries). Optimising before measuring is how teams add complexity without changing a single metric.
Why most React performance work fails
Nearly every slow React app I have inherited was already 'optimised'. It had memo everywhere, useCallback on handlers that never crossed a memo boundary, and a lazy-loaded modal that weighed 4 KB. What it did not have was a measurement. Performance work without a baseline is refactoring with extra steps.
The order below matters more than any single technique. Render cost, byte cost and waterfall cost are three different problems with three different tools, and applying the wrong tool is what makes optimisation feel unproductive.
Step 1 — Measure before you touch anything
- Record a React Profiler session for the interaction that feels slow, not for the page in general.
- Throttle CPU 4× in Chrome DevTools — desktop numbers hide almost every real problem.
- Capture Core Web Vitals from real users (INP and LCP) rather than a single Lighthouse run.
- Write the baseline number down. If you cannot state the before value, you cannot claim an after.
Step 2 — Cut render work
The cheapest render is the one that never happens. Before reaching for memo, move state down to the component that actually uses it — most 'the whole page re-renders' problems are a single piece of state living too high in the tree.
// Before: typing in the filter re-renders the entire dashboard
function Dashboard({ rows }) {
const [query, setQuery] = useState("");
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ExpensiveChart rows={rows} />
<Table rows={rows.filter((r) => r.name.includes(query))} />
</>
);
}
// After: the query lives with the two components that need it
function Dashboard({ rows }) {
return (
<>
<ExpensiveChart rows={rows} />
<FilterableTable rows={rows} />
</>
);
}Lists are where render cost actually lives
A table of 2,000 rows re-rendering on every keystroke is the single most common React performance bug in commerce and dashboard apps. Virtualise anything above roughly 100 rows, debounce the input that drives filtering, and keep the row component memoised with primitive props.
Step 3 — Cut bytes
| Problem | Symptom | Fix |
|---|---|---|
| Barrel imports | Whole icon or util library in the bundle | Import the exact module path |
| Moment / heavy date libs | 70 KB+ for formatting a date | Intl.DateTimeFormat or date-fns |
| No route splitting | One giant entry chunk | Split at the route boundary first |
| Duplicate deps | Two React or two lodash copies | Dedupe and pin in the lockfile |
Set a budget and enforce it in CI. A bundle analyser that nobody looks at after the first week does not prevent regressions; a build that fails at 200 KB gzipped does.
Step 4 — Cut waterfalls
Once the app renders quickly, the remaining latency is usually sequential data fetching: the layout fetches the user, then the page fetches the list, then a card fetches its detail. Hoist requests to the route loader so they run in parallel, and put Suspense boundaries around genuinely optional content rather than around the whole page.
export const Route = createFileRoute("/dashboard")({
loader: async ({ context }) => {
const [user, orders] = await Promise.all([
context.queryClient.ensureQueryData(userQuery),
context.queryClient.ensureQueryData(ordersQuery),
]);
return { user, orders };
},
});Step 5 — Protect the win
Performance regresses by default, one innocent import at a time. Add a bundle-size check and a Lighthouse CI run to the pipeline, and record the interaction latency of your two most-used flows in a dashboard someone actually reads.
Every optimisation you cannot re-measure in six months is a story, not an engineering result.
Checklist
- Profiler session recorded for the specific slow interaction
- CPU throttled 4× while testing
- State colocated before any memoisation added
- Lists over ~100 rows virtualised, filter inputs debounced
- Route-level code splitting in place
- No barrel imports of large libraries
- Route data fetched in parallel, not per-component
- Bundle-size budget enforced in CI
- INP and LCP tracked from real users
Common mistakes
- Wrapping everything in memo/useCallback without a profiling baseline.
- Memoising a component while passing it a freshly created object each render.
- Code splitting tiny components instead of routes.
- Optimising a page nobody visits while the checkout stays slow.
- Reporting a Lighthouse score from a fast laptop as the user experience.
Frequently asked questions
Should I use React.memo everywhere?
No. memo adds a props comparison on every render and only pays off for expensive subtrees with stable props. Apply it after profiling identifies a component that renders often and costs real milliseconds.
Does the React Compiler make manual memoisation obsolete?
It removes most of the mechanical useMemo and useCallback work, but it cannot fix architectural problems: state placed too high, unvirtualised lists, oversized bundles or sequential data fetching all remain your responsibility.
What is a good bundle size for a React app?
Aim for under 200 KB gzipped of JavaScript on the initial route for a content-heavy site, and under 350 KB for an application shell. The exact figure matters less than having a budget enforced in CI.
Which metric should I optimise first?
LCP for landing and marketing pages, INP for applications. Both correlate with revenue far more reliably than a composite Lighthouse score.
Summary
Measure the specific slow interaction, reduce render work by colocating state and virtualising lists, reduce bytes with route splitting and honest imports, remove data waterfalls by loading in parallel, then lock the result in with budgets in CI.
working on something like this?