modern-react-spa

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

  1. Open React DevTools → Profiler tab.
  2. Click the record button.
  3. Reproduce the slow interaction.
  4. 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 / memo wrappers — 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-virtual would 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-virtualreact-virtualizedreact-window
Size (gzip)~3 KB~30 KB~6 KB
API styleHook (headless)ComponentsComponents
SSR-compatibleYesLimitedYes
Variable row heightsYes (measureElement)YesYes (Variable*)
Complex grids (cell-level)Build itYes (<Grid>)Limited
Active maintenanceActiveMaintenanceActive
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 importnew 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

  1. Profiling on a fast dev machine; real users are on cheaper hardware. Use Chrome DevTools’ CPU throttling (4× slowdown).
  2. Manual useMemo everywhere “for performance” after the compiler is on → noise.
  3. Subscribing to a Zustand store without a selector.
  4. Virtualising too aggressively (a 20-row list doesn’t need it).
  5. Web Worker for trivial work; the message-passing cost dominates.
  6. Forgetting overscan in virtual lists → blank frames during fast scroll.
  7. Animating top/left in virtualised rows → use transform instead.

✅ Recap

  • Profile first, optimise second.
  • Compiler handles ~80 % of memoization; the rest needs your judgement.
  • Three virtualisation libraries; @tanstack/react-virtual is 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

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.

Topics in this chapter (5)
  1. 32.1 Profiling with React DevTools Profiler
  2. 32.2 What the React 19 compiler removes — and what it doesn’t
  3. 32.3 Virtualization for long lists, tables, grids
  4. 32.4 Avoiding render storms in global stores
  5. 32.5 Web workers for CPU-bound work