Performance Overview
React is fast by default, but real applications slow down for predictable reasons: components re-render more than they need to, and the browser downloads and parses more JavaScript than it should. Almost every React performance problem reduces to one of those two levers. This page maps the techniques you can reach for, explains when each one actually helps, and—most importantly—pushes you to measure before you optimize.
The two big levers
There are exactly two costs you can influence in a React app.
- Render work — how much your components compute and reconcile when state or props change. This is CPU time on the main thread, and it shows up as janky typing, slow list updates, and dropped frames.
- JavaScript shipped — how many bytes the user downloads, parses, and executes before the page is interactive. This shows up as a slow first load and a poor Time to Interactive.
Most of the React performance toolkit attacks one of these. Memoization and key stability reduce render work; code splitting and bundle analysis reduce JavaScript shipped; concurrent features change when and how work is scheduled so the app stays responsive even when work is unavoidable.
| Lever | Symptom | Primary tools |
|---|---|---|
| Reduce render work | Janky interactions, slow lists | React.memo, useMemo/useCallback, key stability, virtualization, context splitting |
| Ship less JavaScript | Slow first load, high TTI | Code splitting, lazy + Suspense, bundle analysis, asset optimization |
| Schedule work better | UI freezes during heavy updates | useTransition, useDeferredValue, concurrent rendering |
Measure first, always
The single most important rule: do not optimize from intuition. React’s render behavior is often counterintuitive, and a “fix” applied to the wrong component adds complexity without moving any metric.
Use the React DevTools Profiler to find which components actually re-render and how long they take. For load performance, use Lighthouse and the browser’s Performance panel. Wrap suspicious subtrees with the built-in <Profiler> to capture commit timings programmatically.
import { Profiler } from "react";
function onRender(id, phase, actualDuration) {
console.log(`${id} [${phase}] took ${actualDuration.toFixed(1)}ms`);
}
export function Dashboard() {
return (
<Profiler id="Dashboard" onRender={onRender}>
<Widgets />
</Profiler>
);
}
Output:
Dashboard [mount] took 18.4ms
Dashboard [update] took 2.1ms
Tip: If a component renders in under a millisecond, memoizing it usually costs more (in comparison work and code complexity) than it saves. Profile, then optimize the real hotspots.
Reducing render work
When the profiler shows a component re-rendering needlessly, you have a few precise tools.
- Understand why renders happen before reaching for fixes—state changes, parent renders, context updates, and new prop identities all trigger renders. See why renders happen.
React.memoskips a component’s render when its props are shallowly equal to the previous ones. It only helps when the parent re-renders frequently and the props are stable.useMemoanduseCallbackpreserve the identity of computed values and functions across renders, so memoized children and effect dependencies don’t fire spuriously.- Stable keys let React’s reconciler reuse DOM nodes instead of destroying and recreating them—a common, invisible source of wasted work in lists.
import { memo, useCallback, useState } from "react";
const Row = memo(function Row({ item, onSelect }) {
return <li onClick={() => onSelect(item.id)}>{item.name}</li>;
});
function List({ items }) {
const [selected, setSelected] = useState(null);
// Stable identity → Row's memoization actually holds.
const handleSelect = useCallback((id) => setSelected(id), []);
return (
<ul>
{items.map((item) => (
<Row key={item.id} item={item} onSelect={handleSelect} />
))}
</ul>
);
}
For very long lists, no amount of memoization beats simply not rendering off-screen rows—that’s virtualization. And when global state causes wide re-renders, splitting context narrows the blast radius.
Shipping less JavaScript
The fastest code is the code you never send. Code splitting breaks your bundle into chunks that load on demand, so the initial payload only includes what the first screen needs.
import { lazy, Suspense } from "react";
const SettingsPanel = lazy(() => import("./SettingsPanel.jsx"));
function App({ showSettings }) {
return (
<Suspense fallback={<Spinner />}>
{showSettings && <SettingsPanel />}
</Suspense>
);
}
Pair this with a bundle analyzer to find oversized dependencies, and optimize images and other assets, which often dwarf your JavaScript in raw bytes.
Scheduling work with concurrent features
React 18+ can keep the UI responsive even when there’s genuinely a lot to render. Transitions mark updates as non-urgent so typing stays smooth while an expensive list re-filters in the background.
import { useState, useTransition } from "react";
function Search({ data }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState(data);
const [isPending, startTransition] = useTransition();
function onChange(e) {
setQuery(e.target.value); // urgent: keeps input responsive
startTransition(() => {
setResults(data.filter((d) => d.includes(e.target.value)));
});
}
return (
<>
<input value={query} onChange={onChange} />
{isPending && <span>Updating…</span>}
<Results items={results} />
</>
);
}
Concurrent rendering also powers Suspense-based data loading, letting React stream and prioritize work instead of blocking on it.
Best practices
- Profile before optimizing—fix the components the data points to, not the ones you suspect.
- Treat memoization as a targeted tool, not a default; unnecessary memoization adds cost and complexity.
- Keep list keys stable and derived from data identity, never from array index.
- Split bundles along route and interaction boundaries so the first screen ships minimal JavaScript.
- Use transitions and deferred values to keep input responsive during heavy updates.
- Re-measure after every change to confirm the metric you care about actually moved.