A12 · Part 3 · Advanced
Performance work
Measure, find the actual cost, fix the structure. Memoisation is the last step, not the first.
08On this page
Decide what is slow first
| Symptom | Usually | Look at |
|---|---|---|
| Slow first load | Bundle size, or a blocking request | Network panel, bundle analyser |
| Typing lags | Re-rendering a large tree per keystroke | React Profiler |
| Scrolling stutters | Too many DOM nodes, or layout thrashing | Performance panel |
| Click feels dead | Long task blocking the main thread | Performance panel, INP |
| Layout jumps | Images without dimensions, late-loading fonts | CLS in Lighthouse |
These have almost nothing in common. "The app is slow" answered with
memo is a guess; each row above has a different fix.
Reading the React Profiler
Record an interaction, then read the flame chart: width is time in that component and its children, grey means it did not re-render. Turn on "record why each component rendered" in the settings — without it you are looking at symptoms.
| Reason shown | Means |
|---|---|
| Props changed | Correct behaviour — unless the prop is a new object with the same contents |
| Parent rendered | A missing memo, or state that should live lower |
| Context changed | An unmemoised provider value, or one context doing too much |
| Hooks changed | A hook returning a new reference every render |
Fix the structure before adding a cache
// ✗ every keystroke re-renders the expensive tree
function Page() {
const [q, setQ] = useState("");
return <><input value={q} onChange={...} /><Expensive /></>;
}
// ✓ state moved down — Expensive is now a sibling that never re-renders
function Page() {
return <><SearchBox /><Expensive /></>;
}
// ✓ or pass it as children — the prop is the same element object each time
function Page({ children }) {
const [q, setQ] = useState("");
return <><input value={q} onChange={...} />{children}</>;
}
Both remove the problem rather than caching around it, and neither adds a dependency array anybody has to maintain.
Long lists: virtualise
const rows = useVirtualizer({ count: items.length, estimateSize: () => 48, ... });
Five thousand rows means five thousand DOM nodes, and the browser — not
React — is what struggles. Virtualisation renders the twenty that are
visible. No amount of memo substitutes for it.
It has real costs: Ctrl-F stops finding off-screen content, and it complicates variable heights and accessibility. Reach for it past a few hundred rows, not before.
The bundle
npx vite-bundle-visualizer // or @next/bundle-analyzer
- Split by route first — the biggest win per minute of work.
- Find the one dependency that is a third of the bundle. There usually is one: a date library, an icon set imported wholesale, a chart package.
- Lazy-load what most people never open — an editor, a modal, an export dialog.
- Check for duplicates. Two versions of the same package is common and invisible without the analyser.
What users actually measure
| Metric | Good | Usually caused by |
|---|---|---|
| LCP largest contentful paint | < 2.5s | A slow server response, or a hero image that is not preloaded |
| INP interaction to next paint | < 200ms | Long tasks — usually a big re-render or an unyielding loop |
| CLS cumulative layout shift | < 0.1 | Images without dimensions, fonts swapping, content injected above the fold |
INP is where React work shows up. A component that takes 300ms to re-render on click is a failing INP, and a transition often fixes it without making anything faster — it just stops the urgent update queueing behind the slow one.
Things that are not React's fault
- Layout thrashing. Reading
offsetHeightafter a write forces a synchronous layout. In a loop, that is a freeze. Batch reads, then writes. - Animating
leftandtop. Those trigger layout every frame.transformandopacityrun on the compositor. - Unoptimised images. Frequently more bytes than the entire JavaScript bundle.
- A blocking third-party script. Check what is on the critical path before profiling your own code.
The order to work in
- Measure on a throttled CPU and connection. Your machine is not the user's.
- Find the biggest single cost — usually one component, one dependency, or one request.
- Fix the structure: move state down, split the component, virtualise, code-split.
- Then memoise, at the exact place the profiler pointed at.
- Measure again, and delete the optimisation if it did not help.
Interview answer, one sentence
"I profile before changing anything, because 'slow' means five different
things and they have five different fixes. Most React slowness is either a
large tree re-rendering on every keystroke — fixed by moving state down
rather than by memoising — or too many DOM nodes, which needs
virtualisation. Memoisation is the last step and only where the profiler
pointed — and where the React Compiler is enabled it inserts most of that
memoisation itself, so hand-written useMemo becomes the
exception rather than the habit."
Opens in the editor: write it, run it, and check it against real tests.
All 54 for this level →Finished reading?
0 of 57 chapters read in this series