---
name: react-performance
description: Diagnose and fix React performance problems — slow renders, re-render storms, large bundles, poor Core Web Vitals (LCP, INP, CLS), memory leaks, and janky lists. Use this whenever the user mentions React being slow, laggy, "re-rendering too much", bundle size, Lighthouse scores, React DevTools Profiler, useMemo/useCallback/memo decisions, virtualisation, code splitting, Suspense, or React Compiler. Also use it proactively when reviewing React code that maps over large arrays, creates objects/functions inline in JSX passed to memoised children, or fetches in effects.
metadata:
  technology: React
  type: performance
---

# React Performance

Performance work is measurement first, then the smallest fix that moves the number. Never recommend `useMemo` everywhere; recommend a profiler session.

## 1. Measure before touching code

Ask which of these the user actually has, and get a number:

| Symptom | Tool | Metric to capture |
|---|---|---|
| Page loads slowly | Lighthouse / PageSpeed, WebPageTest | LCP, TTFB, total JS transferred |
| Interactions feel laggy | Chrome Performance panel, Web Vitals extension | INP, long tasks > 50 ms |
| "It re-renders too much" | React DevTools Profiler → "Record why each component rendered" | Commit count per interaction, render duration |
| Layout jumps | Lighthouse, Layout Shift regions in DevTools | CLS |
| Memory grows over time | DevTools Memory → heap snapshots between navigations | Retained size of detached nodes / listeners |
| Bundle is big | `source-map-explorer`, `@next/bundle-analyzer`, `vite-bundle-visualizer` | Largest chunks, duplicated deps |

Write the baseline down. Every fix is judged against it.

## 2. Rendering: the re-render decision tree

React re-renders a component when its parent renders, its state changes, or a context it consumes changes. Work through in this order:

1. **Is the re-render actually expensive?** A component rendering in < 1 ms 50 times is fine. Fix only what the Profiler shows as a long commit.
2. **Move state down.** If a parent holds state only one child needs, move it into that child. Eliminates the parent render entirely.
3. **Lift content up.** Pass expensive static subtrees as `children` — React skips re-rendering identical `children` element references.
4. **Split context.** One `AppContext` with 15 values re-renders every consumer on any change. Split by change frequency, or move to Zustand/Jotai selectors.
5. **Then, and only then, memoise.** `memo(Component)` on the expensive leaf, and `useMemo`/`useCallback` on the props passed to it so the memo actually holds.
6. **React Compiler.** If the project is on React 19+, enable the compiler (`babel-plugin-react-compiler`); it auto-memoises and most manual `useMemo`/`useCallback` can be deleted. Verify with the Profiler that memoisation is happening.

Memoisation footguns to flag in review:
- `memo` on a component that receives a new object/array/function prop every render — the memo never hits.
- `useMemo` for a computation cheaper than the memo check itself (string concat, small filters).
- Dependency arrays with objects recreated each render.

## 3. Lists

- **> ~100 rows rendered at once → virtualise.** `@tanstack/react-virtual` (headless) or `react-window`. Fixed row height is far cheaper than dynamic; measure only if design requires it.
- Stable `key`s from data ids, never array index for reorderable or filterable lists (index keys cause state to leak between rows and full remounts).
- Row components wrapped in `memo`; row callbacks bound via `data-*` attributes and one delegated handler, or via `useCallback` with stable ids.
- Paginate or infinite-scroll on the server; don't fetch 10 000 records to virtualise them client-side.

## 4. Loading and bundle size

- **Route-level code splitting** is mandatory; component-level (`lazy()` + `Suspense`) for heavy widgets (charts, editors, maps).
- Analyse the bundle: look for full-library imports (`import _ from 'lodash'` → `lodash-es` per-function), moment.js (→ `date-fns`/Temporal), duplicate React copies in monorepos, icon libraries pulling every icon.
- Prefer platform APIs: `Intl.NumberFormat`/`DateTimeFormat` instead of formatting libraries.
- Ship modern JS: set `browserslist` to real users; drop legacy polyfills.
- Images: `next/image` or `<img loading="lazy" decoding="async">` with explicit `width`/`height`, AVIF/WebP, responsive `srcset`. Images are the #1 LCP cause.
- Fonts: `font-display: swap` or `optional`, preload the one critical font, subset it.
- Third-party scripts: load with `next/script strategy="lazyOnload"` or `requestIdleCallback`; measure their long tasks separately — they are often the whole INP problem.

## 5. Server-side and data-fetching performance (Next.js / RSC)

- Fetch in Server Components and stream with `<Suspense>` boundaries so the shell paints before slow data arrives.
- Parallelise independent fetches with `Promise.all`; sequential awaits are the most common server waterfall.
- Cache: `fetch` with `next: { revalidate }` or `unstable_cache`; tag-based invalidation on mutation.
- Keep client components small — every `"use client"` boundary ships its whole subtree's JS.
- `generateStaticParams` + ISR for content pages; dynamic rendering only for personalised routes.

## 6. Interaction responsiveness (INP)

- Keep event handlers under 50 ms. Move heavy work to `startTransition` (non-urgent state updates) or a Web Worker (CPU-bound parsing, search indexing).
- `useDeferredValue` for expensive derived UI driven by fast input (search-as-you-type).
- Debounce network calls, not the UI update itself — the input must echo instantly.
- Avoid layout thrash: batch DOM reads then writes; never read `offsetHeight` inside a loop that also mutates styles.
- `content-visibility: auto` and `contain: layout` on long off-screen sections.

## 7. Memory leaks

Look for these when heap grows across navigations:
- Subscriptions (`addEventListener`, WebSocket, `setInterval`, store `subscribe`) without cleanup in the effect return.
- Closures capturing large data in long-lived callbacks (e.g. a global event bus).
- Detached DOM kept alive by refs stored in module scope.
- Query caches with no `gcTime`/`staleTime` for huge, rarely-revisited datasets.

## 8. Report format

When delivering a performance review, use this structure:

```
## Baseline
<metric>: <value> (tool, device, network)

## Findings (ordered by impact)
1. <problem> — <evidence from profiler/bundle> — est. impact
2. ...

## Fixes
For each finding: the minimal change, code diff, expected metric movement.

## Verify
Re-measure the same metric under the same conditions; attach before/after.
```

## Anti-patterns to reject

- "Wrap everything in `useMemo`/`useCallback`" — adds cost and hides the real cause.
- `React.memo` on every component by policy.
- Fetching in `useEffect` then rendering a spinner — waterfall; use a query library, loaders, or RSC.
- Storing derived data in state and syncing with effects.
- Inline `style={{}}` objects and arrow functions passed to memoised children.
- Disabling StrictMode to "fix" double renders in dev.
- Skipping measurement because "it's obviously the list".
