Chapter 32
Runtime Performance
Runtime performance for React 19.2 SPAs — DevTools Profiler, what the compiler optimises, list virtualization, avoiding render storms, and web workers for CPU-bound work.
Published 2026-05-23
🎯 Chapter Goal — After this chapter you can profile a React 19.2 SPA with DevTools, understand what the compiler does and doesn’t optimise, virtualise long lists and tables with the right library for your case, avoid render storms in global stores, and push CPU work to web workers when needed.
🧭 Prerequisites — Ch 2 (Compiler), Ch 13 (Zustand), Ch 17 (Vite).
🔹 32.1 Profiling with React DevTools Profiler
The Profiler is the only honest way to know what’s slow.
Recording a session
- Open React DevTools → Profiler tab.
- Click the record button.
- Reproduce the slow interaction.
- Stop recording.
You get a flame chart. Each bar is a component render. Width = time spent.
What to look for:
- Wide bars at the bottom — leaf components rendering for long. Often: an unmemoized child of a frequently-updating parent.
- Many small bars at the top — a parent’s update is re-rendering hundreds of children. Often: a Context update fanout (Ch 13 §13.6).
- A render that fires when it shouldn’t — hover the bar; “why did this render?” shows changed props / state / hooks.
┌─ Profiler — Dashboard interaction ─────────────────────┐
│ Commit 1 ████████████████████████████ 85 ms │
│ <Dashboard> ████████████████ 40 ms │
│ <InvoiceTable> ████████████ 35 ms ◀── investigate │
│ <Row> × 500 ████████ │
│ <Sidebar> ███ 3 ms │
└────────────────────────────────────────────────────────┘
A wide <InvoiceTable> rendering 500 rows on every interaction is the canonical “needs virtualisation” signal.
🔹 32.2 What the React 19 compiler removes — and what it doesn’t
Ch 2 §2.1 covered this in depth. Summary for the perf context:
The compiler removes:
- Most
useMemo/useCallback/memowrappers — automatic now. - Re-renders triggered by stable props that happen to look unstable to React’s reconciler.
The compiler does NOT remove:
- Re-renders from context updates (Ch 13 §13.6).
- Re-renders from external store updates that aren’t selector-narrowed.
- Render work that’s intrinsically large (rendering 10,000 rows).
- Effects that fire too often.
The check: open DevTools Profiler post-compiler. If you still see wide bars on stable props, the compiler isn’t the issue; the work itself is.
🔹 32.3 Virtualization for long lists, tables, grids
Rendering 10,000 rows takes 10,000 × (per-row work) milliseconds. Virtualisation renders only the visible rows — usually 20–50 — and tracks scroll position.
32.3.1 @tanstack/react-virtual — the modern default
https://tanstack.com/virtual/latest — headless, hook-based. Works with any rendering shape.
import { useVirtualizer } from '@tanstack/react-virtual';
const VirtualInvoiceList = ({ invoices }: { invoices: Invoice[] }) => {
const parentRef = useRef<HTMLDivElement>(null);
const v = useVirtualizer({
count: invoices.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48, // px per row
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: v.getTotalSize(), position: 'relative' }}>
{v.getVirtualItems().map((vi) => (
<div
key={vi.key}
style={{ position: 'absolute', top: vi.start, height: vi.size, width: '100%' }}
>
<InvoiceRow invoice={invoices[vi.index]} />
</div>
))}
</div>
</div>
);
};
Pros: small (~3 KB gzip), works with any layout, framework-agnostic core. Cons: more code than component-based alternatives.
32.3.2 react-virtualized — the legacy battle-tested option
https://www.npmjs.com/package/react-virtualized — large component library with <List>, <Table>, <Grid>, <Masonry>, <InfiniteLoader>, <AutoSizer>.
Use when:
- You’re already on it; migrating costs more than staying.
- You need complex grid use cases (Excel-style) that
@tanstack/react-virtualwould require building. - The team prefers component-based APIs.
Don’t use when: starting fresh — @tanstack/react-virtual or react-window is the modern path.
32.3.3 react-window — the slim successor
Same author as react-virtualized, much smaller (~6 KB), simpler API:
import { FixedSizeList } from 'react-window';
<FixedSizeList height={600} itemCount={invoices.length} itemSize={48} width="100%">
{({ index, style }) => (
<div style={style}><InvoiceRow invoice={invoices[index]} /></div>
)}
</FixedSizeList>
The middle ground between react-virtualized’s sprawl and @tanstack/react-virtual’s headless verbosity.
32.3.4 Decision matrix
| Concern | @tanstack/react-virtual | react-virtualized | react-window |
|---|---|---|---|
| Size (gzip) | ~3 KB | ~30 KB | ~6 KB |
| API style | Hook (headless) | Components | Components |
| SSR-compatible | Yes | Limited | Yes |
| Variable row heights | Yes (measureElement) | Yes | Yes (Variable*) |
| Complex grids (cell-level) | Build it | Yes (<Grid>) | Limited |
| Active maintenance | Active | Maintenance | Active |
| Recommended for new code | ✅ default | ⚠️ legacy only | ✅ alternative |
32.3.5 Common pitfalls
- Variable row heights without
measureElement→ scroll jumps. - Sticky headers that don’t account for virtualised offsets → headers vanish.
- Keyboard focus on a virtualised row that scrolls out → focus lost.
- Animations during scroll → frame drops; pause animations during scroll.
🔹 32.4 Avoiding render storms in global stores
Ch 13 §13.6 covered the pattern. The performance angle:
The storm: a Zustand store with a Context-as-global-state pattern → every consumer re-renders on every store update.
The fix: selectors.
// ❌ subscribes to the whole store
const all = useStore();
// ✅ subscribes to one slice
const items = useStore((s) => s.items);
const count = useStore((s) => s.items.length);
Run the Profiler on a known-storming flow (e.g., typing in a text input that updates a store). The “before” shows dozens of unrelated re-renders; the “after” shows only the typing input.
For Context that you can’t migrate immediately: split into multiple contexts (one per concern). Each consumer subscribes only to the concern it needs.
🔹 32.5 Web workers for CPU-bound work
The browser’s main thread is shared with React’s rendering. CPU-bound work (parsing large JSON, decoding images, computing layouts) blocks rendering — INP suffers.
The pattern: push the work to a Web Worker. Browser API is verbose; Comlink makes it ergonomic.
// worker.ts
import { expose } from 'comlink';
const api = {
parseAndCompute(jsonText: string): ComputedResult {
const data = JSON.parse(jsonText);
// ...expensive computation
return result;
},
};
expose(api);
// main thread
import { wrap } from 'comlink';
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
const api = wrap<typeof workerApi>(worker);
const result = await api.parseAndCompute(largeJson);
// returns Promise; main thread stayed responsive
Vite handles the worker import — new Worker(new URL('./worker.ts', import.meta.url)) is the canonical syntax; Vite bundles the worker as its own file.
When NOT to use workers:
- Fast work (< 10 ms). The structured-clone overhead of postMessage isn’t free.
- Work that touches the DOM. Workers can’t.
- Work that’s mostly waiting on network. Use async/await on the main thread.
🪤 Common Pitfalls
- Profiling on a fast dev machine; real users are on cheaper hardware. Use Chrome DevTools’ CPU throttling (4× slowdown).
- Manual
useMemoeverywhere “for performance” after the compiler is on → noise. - Subscribing to a Zustand store without a selector.
- Virtualising too aggressively (a 20-row list doesn’t need it).
- Web Worker for trivial work; the message-passing cost dominates.
- Forgetting
overscanin virtual lists → blank frames during fast scroll. - Animating
top/leftin virtualised rows → usetransforminstead.
✅ Recap
- Profile first, optimise second.
- Compiler handles ~80 % of memoization; the rest needs your judgement.
- Three virtualisation libraries;
@tanstack/react-virtualis the safe default in 2026. - Selectors are the difference between a Zustand store that scales and one that doesn’t.
- Web workers for CPU-bound work; not for the trivial stuff.
🔗 Further Reading
- https://tanstack.com/virtual/latest — TanStack Virtual (default recommendation).
- https://www.npmjs.com/package/react-window —
react-window(smaller, simpler). - https://www.npmjs.com/package/react-virtualized —
react-virtualized(legacy reference). - https://github.com/GoogleChromeLabs/comlink — Comlink (workers with a nice API).
- https://web.dev/articles/optimize-inp — INP-specific guidance.
- Ch 2 (Compiler), Ch 13 (Zustand selectors), Ch 30 (budgets).
In the book — not on the site
Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.