React Performance Audit
Performance work goes wrong in two directions: optimizing what was never slow, and
sprinkling useMemo until the code is unreadable and no faster. This skill enforces the
measure → diagnose → fix → verify loop, and refuses to skip the first step.
Step 0 — Establish what "slow" means here
Before reading any code, get the symptom and the number. Ask which of these it is, because each has a different root cause family:
- Slow initial load → bundle size, server response, render-blocking resources → LCP
- Slow interaction / laggy typing → re-render cost, long tasks on the main thread → INP
- Layout shifting while loading → missing dimensions, late-injected content → CLS
- Slow list scrolling → per-row render cost, missing virtualization
- Slow navigation between routes → unsplit bundles, waterfall data fetching
If the user has no measurement, provide one before diagnosing: a Lighthouse run, a React
DevTools Profiler recording, or npx source-map-explorer on the build output. An audit
without a baseline cannot demonstrate improvement and usually optimizes the wrong thing.
Step 1 — Static scan
Run the bundled scanner over the source to collect candidate issues fast:
node scripts/perf_scan.mjs <src-dir>
It flags high-signal patterns: inline object and arrow props passed to memoized children,
array index keys, useEffect with a missing or over-broad dependency array, state that
should be derived, context values constructed inline, heavy top-level imports, and
useState holding values that never trigger a render.
Treat its output as leads, not verdicts. The scanner cannot see how often a component renders or how expensive that render is, and a flagged pattern in a component that renders twice a session is not a problem worth a PR.
Step 2 — Diagnose against the real cost model
React re-renders a component when its state changes, its context value changes, or its parent re-renders. The last one is the source of most confusion — a parent re-render re-renders all children regardless of whether their props changed, unless the child is memoized and its props are referentially stable.
Work through these in order of typical impact:
Context that changes too often. A context whose value is a fresh object every render
invalidates every consumer in the tree. Split contexts by update frequency — a stable
dispatch-like context and a volatile state context — and memoize the value. This single
fix routinely removes more wasted renders than every React.memo in a codebase.
Uncontrolled subtree re-renders. Lifting state higher than needed makes the whole tree
re-render on every keystroke. Push state down to the smallest component that owns it, or
move the expensive siblings into children so they keep the same element identity across
parent renders.
Memoization that does nothing. React.memo on a component receiving style={{...}}
or onClick={() => ...} compares a new reference every time and always re-renders, having
added a comparison cost for no benefit. Either stabilize the props or drop the memo.
Over-memoization. useMemo on a cheap computation costs a dependency comparison plus
retained memory to save a few microseconds. Reserve it for expensive derivations, large
list transforms, and referential stability that something downstream actually depends on.
With the React Compiler enabled, most manual memoization becomes redundant — check for it
before adding more.
Expensive renders rather than frequent ones. Long lists without virtualization, chart libraries rendering thousands of DOM nodes, date formatting inside a map. Fix the cost per render, not the count.
Bundle weight. Check for moment, full lodash, unshaken icon packs, and duplicated
transitive dependencies. Route-level React.lazy and dynamic import() for anything below
the fold; next/dynamic with ssr: false for browser-only widgets.
Data waterfalls. A component fetching, then rendering a child that fetches, serializes round trips. Hoist to a parallel fetch, or in the App Router move the fetch into a Server Component and stream with Suspense.
Step 3 — Report
Use this structure so the reader can act without reading everything:
## Summary
One paragraph: what's slow, why, expected gain from the top three fixes.
## Baseline
| Metric | Current | Target |
|---|---|---|
| LCP / INP / CLS / bundle (gzip) | | |
## Findings
### 1. <Title> — Impact: High | Effort: Low
**Where:** `src/path/File.tsx:42`
**What happens:** the mechanism, in terms of React's render model
**Evidence:** profiler number, bundle bytes, or scanner hit
**Fix:** minimal diff
**Expected gain:** quantified, with the assumption stated
## Not worth fixing
Patterns that look wrong but cost nothing here — with the reason. This section keeps the
team from "fixing" things later and reintroducing complexity.
## Verification
The exact command or profiling step that confirms each fix landed.
Order findings by impact-to-effort, never by file order. Cap the list at the eight that matter; a 40-item audit is ignored.
Step 4 — Verify and prevent
Every fix needs a before/after number from the same measurement used in Step 0. Then close the loop so the regression cannot silently return:
- Bundle budget in CI (
size-limitor@next/bundle-analyzerwith a threshold that fails) - Lighthouse CI on preview deployments with asserted budgets
- Real-user monitoring via
web-vitalsreporting INP and LCP at p75, since lab numbers on fast hardware hide the problem that users actually have
Anti-patterns to avoid in your own recommendations
Do not recommend wrapping everything in memo, do not suggest useCallback for handlers
passed to plain DOM elements, and do not present a micro-optimization as a headline fix
when a 400 KB dependency is sitting in the entry chunk. Credibility in a performance review
comes from correct prioritization more than from knowing obscure APIs.
For the mechanics of profiling — Profiler flame chart reading, why-did-you-render setup,
Chrome performance traces, and INP attribution — see references/measuring.md.