🔬 Chapter 27: React Performance Profiling
Measuring what happens after the JavaScript arrives — which components re-render, what a state change actually costs, and how to stop a regression reaching production.
📖 Plain English Explanation
Chapter 22 was about download: ship less JavaScript, don't wait on waterfalls, cache what doesn't change. That's the first half of performance, and for most Next.js apps it's the bigger half.
This chapter is the second half: what your app does once it's running.
The two are genuinely different problems with different tools. You can ship a 40KB bundle and still have a table that freezes for 300ms every time someone types a character. Lighthouse won't tell you that. The bundle analyzer won't tell you that. The Network tab won't tell you that.
The symptom is always some version of:
"It feels laggy when I click / type / drag, but I can't see anything wrong."
And the cause is almost always one of three things:
- Something re-renders that didn't need to — a parent re-renders, so 200 children re-render, so the browser recalculates layout for all of them.
- A single render is genuinely expensive — filtering 50,000 rows on every keystroke.
- The work is on the critical path — it has to happen, but it's blocking the response to the user's input instead of happening after it.
The tools in this chapter tell you which one you have. In rough order of how often you'll reach for them:
React Scan → "what is re-rendering right now?" (10 seconds)
React DevTools → "why did it re-render, and what did it cost?"
<Profiler> → "measure this specific subtree, in production"
performance.measure → "how long does THIS interaction take?"
render-count tests → "stop it regressing"
Lighthouse CI → "stop the whole page regressing"
A caveat that matters in Next.js
Server Components don't re-render. They run once on the server and produce output. No amount of profiling will find a re-render problem in a component that never re-renders.
So everything in this chapter applies to your client islands — the 'use client' leaves from Chapter 10. If your app is mostly Server Components, you may find you have no render performance problem at all, and that's the correct outcome. Profile before you optimize, same as always.
🔴 React Scan — start here
The fastest way to answer "what's re-rendering?" React Scan draws an outline around every component as it re-renders, live, with no code changes.
npx react-scan@latest localhost:3000
That's it — it opens a browser pointed at your dev server with scanning enabled. Nothing installed, nothing imported.
To have it always on in development, add it to your root layout:
// app/layout.tsx
import Script from 'next/script'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
{process.env.NODE_ENV === 'development' && (
<Script
src="https://unpkg.com/react-scan/dist/auto.global.js"
strategy="beforeInteractive"
crossOrigin="anonymous"
/>
)}
</head>
<body>{children}</body>
</html>
)
}
// app/layout.js
import Script from 'next/script'
export default function RootLayout({ children }) {
return (
<html lang="en">
<head>
{process.env.NODE_ENV === 'development' && (
<Script
src="https://unpkg.com/react-scan/dist/auto.global.js"
strategy="beforeInteractive"
crossOrigin="anonymous"
/>
)}
</head>
<body>{children}</body>
</html>
)
}
The NODE_ENV guard matters — this is a development tool, and you don't want a third-party script tag in production.
Reading it
Boxes flash around components as they render. What you're looking for:
✅ Type in a search box → only the input and the results list outline
❌ Type in a search box → the entire page outlines, including the header,
the sidebar, and 200 table rows that didn't change
The second one is your bug, and you found it in about ten seconds. React Scan won't tell you why — that's the next tool — but it tells you where to look, which is usually the hard part.
🧭 React DevTools Profiler
Install the React Developer Tools extension, open the Profiler tab, hit record, do the slow thing, hit stop.
The two charts
Flamegraph — the component tree for one commit. Width is time. A wide bar is an expensive component; a wide bar with many children is an expensive subtree.
Ranked — the same commit, sorted by self-time, most expensive first. Start here. It answers "what should I look at" in one glance.
The setting that makes it useful
In the Profiler's settings gear, enable:
☑ Record why each component rendered while profiling
Now clicking any component shows "Why did this render?":
• Props changed: (items, onSelect)
• Hooks changed: 3
• The parent component rendered
That last one — "The parent component rendered" — is the single most common finding in a React performance investigation. It means nothing about this component changed; it re-rendered because its parent did.
Reading the commit timeline
The bar chart across the top is one bar per commit, height = duration. What you want to see when you type one character:
✅ ▁▁▁▂▁▁▁ a few small commits
❌ ▁▁█████▁▁ one 180ms commit ← the frame budget is 16ms
Anything over ~50ms is a dropped frame the user can feel. Anything over 200ms reads as "the app froze".
The most common finding, and its fix
// ❌ every keystroke re-renders all 500 rows
'use client'
export function Table({ rows }: { rows: Row[] }) {
const [query, setQuery] = useState('')
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{rows.map((row) => (
<Row key={row.id} row={row} onSelect={() => select(row.id)} />
))}
</>
)
}
onSelect is a new arrow function on every render, so every Row gets a new prop, so React.memo on Row wouldn't even help. Three ways out, in order of preference:
// ✅ 1. Turn on the React Compiler (Chapter 22) and change nothing
// next.config.ts → reactCompiler: true
// ✅ 2. Move the state down so the rows aren't in the re-rendering subtree
export function Table({ rows }: { rows: Row[] }) {
return (
<>
<SearchBox /> {/* owns the query state */}
<Rows rows={rows} /> {/* doesn't re-render when query changes */}
</>
)
}
// ✅ 3. Memoize by hand (last resort)
const Row = memo(function Row({ row, onSelect }: RowProps) { /* ... */ })
const handleSelect = useCallback((id: string) => select(id), [])
Option 2 is underrated. Lifting state up is a React reflex; pushing it down is usually the actual fix. If the query only affects the input and the results, the state belongs where those two things are — not above the entire table.
⏱️ The <Profiler> component
DevTools is interactive. <Profiler> is programmatic — it's how you measure a subtree in a test, in CI, or in production against real users.
// app/components/ProfiledDashboard.tsx
'use client'
import { Profiler, type ProfilerOnRenderCallback } from 'react'
const onRender: ProfilerOnRenderCallback = (
id, // the Profiler's id prop
phase, // "mount" | "update" | "nested-update"
actualDuration, // ms actually spent rendering this commit
baseDuration, // ms it would take with NO memoization
startTime, // when React began this update
commitTime // when React committed it
) => {
if (actualDuration > 16) {
console.warn(`[${id}] ${phase} took ${actualDuration.toFixed(1)}ms`)
}
}
export function ProfiledDashboard({ children }: { children: React.ReactNode }) {
return (
<Profiler id="Dashboard" onRender={onRender}>
{children}
</Profiler>
)
}
// app/components/ProfiledDashboard.js
'use client'
import { Profiler } from 'react'
function onRender(id, phase, actualDuration, baseDuration, startTime, commitTime) {
if (actualDuration > 16) {
console.warn(`[${id}] ${phase} took ${actualDuration.toFixed(1)}ms`)
}
}
export function ProfiledDashboard({ children }) {
return (
<Profiler id="Dashboard" onRender={onRender}>
{children}
</Profiler>
)
}
actualDuration vs baseDuration — the important pair
This is the part people miss, and it's the single most useful number in the API.
| Meaning | |
|---|---|
baseDuration | What this subtree would cost to re-render with no memoization at all |
actualDuration | What it actually cost this commit |
So the ratio tells you whether your memoization is doing anything:
baseDuration 120ms, actualDuration 118ms → memoization is doing nothing
baseDuration 120ms, actualDuration 4ms → memoization is working
baseDuration 6ms, actualDuration 6ms → fine, nothing to optimize
That first case is the one worth hunting: you have memo and useMemo everywhere, you're paying the comparison cost, and you're getting nothing back — usually because a prop is a fresh object or function on every render.
// Log the ratio to find useless memoization
const onRender: ProfilerOnRenderCallback = (id, phase, actual, base) => {
const saved = base - actual
if (base > 16 && saved / base < 0.1) {
console.warn(
`[${id}] memoization saving only ${((saved / base) * 100).toFixed(0)}% ` +
`(base ${base.toFixed(1)}ms → actual ${actual.toFixed(1)}ms)`
)
}
}
In production
<Profiler> works in production builds. It adds a small amount of overhead, so sample rather than measuring every user:
'use client'
import { Profiler, type ProfilerOnRenderCallback } from 'react'
const SAMPLE_RATE = 0.01 // 1% of sessions
const shouldSample = Math.random() < SAMPLE_RATE
const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration) => {
if (actualDuration < 50) return // only report the bad ones
navigator.sendBeacon(
'/api/perf',
JSON.stringify({ id, phase, duration: actualDuration, path: location.pathname })
)
}
export function Instrumented({ id, children }: { id: string; children: React.ReactNode }) {
if (!shouldSample) return <>{children}</>
return <Profiler id={id} onRender={onRender}>{children}</Profiler>
}
'use client'
import { Profiler } from 'react'
const shouldSample = Math.random() < 0.01
function onRender(id, phase, actualDuration) {
if (actualDuration < 50) return
navigator.sendBeacon(
'/api/perf',
JSON.stringify({ id, phase, duration: actualDuration, path: location.pathname })
)
}
export function Instrumented({ id, children }) {
if (!shouldSample) return <>{children}</>
return <Profiler id={id} onRender={onRender}>{children}</Profiler>
}
Real-user data beats your laptop. Your machine is faster than most of your users' machines, and your network is better.
🕵️ why-did-you-render
When you know something re-renders too much but can't work out which prop changed, this patches React in development and logs the exact difference.
npm install -D @welldone-software/why-did-you-render
// lib/wdyr.ts
import React from 'react'
if (process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render')
whyDidYouRender(React, {
trackAllPureComponents: true,
logOnDifferentValues: true,
})
}
// app/components/WhyDidYouRender.tsx
'use client'
import '@/lib/wdyr'
export function WhyDidYouRender() {
return null
}
// app/layout.tsx — dev only
{process.env.NODE_ENV === 'development' && <WhyDidYouRender />}
Console output looks like:
Row re-rendered because of props changes:
{onSelect: different by reference}
prev: ƒ () {} next: ƒ () {}
"Different by reference" with visually identical values is the signature of an inline arrow function or object literal. That's your fix.
It's a development tool only. It patches React itself — never ship it. And it doesn't understand Server Components, so only apply it to client islands.
📐 Measuring a state transition
This is the question that started this chapter: how long does going from state A to state B actually take?
DevTools gives you a number for one recorded session. To track it over time, or compare a change, you need to measure it yourself. The User Timing API is the tool.
// app/components/FilterableList.tsx
'use client'
import { useState, useEffect, useRef } from 'react'
export function FilterableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState('')
const pendingMeasure = useRef(false)
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
performance.mark('filter-start')
pendingMeasure.current = true
setQuery(e.target.value)
}
// Runs after React has committed AND the browser has painted
useEffect(() => {
if (!pendingMeasure.current) return
pendingMeasure.current = false
requestAnimationFrame(() => {
requestAnimationFrame(() => {
performance.mark('filter-end')
const measure = performance.measure(
'filter-interaction',
'filter-start',
'filter-end'
)
console.log(`filter → paint: ${measure.duration.toFixed(1)}ms`)
})
})
}, [query])
const visible = items.filter((i) => i.name.includes(query))
return (
<>
<input value={query} onChange={handleChange} />
<ul>{visible.map((i) => <li key={i.id}>{i.name}</li>)}</ul>
</>
)
}
// app/components/FilterableList.js
'use client'
import { useState, useEffect, useRef } from 'react'
export function FilterableList({ items }) {
const [query, setQuery] = useState('')
const pendingMeasure = useRef(false)
function handleChange(e) {
performance.mark('filter-start')
pendingMeasure.current = true
setQuery(e.target.value)
}
useEffect(() => {
if (!pendingMeasure.current) return
pendingMeasure.current = false
requestAnimationFrame(() => {
requestAnimationFrame(() => {
performance.mark('filter-end')
const measure = performance.measure(
'filter-interaction',
'filter-start',
'filter-end'
)
console.log(`filter → paint: ${measure.duration.toFixed(1)}ms`)
})
})
}, [query])
const visible = items.filter((i) => i.name.includes(query))
return (
<>
<input value={query} onChange={handleChange} />
<ul>{visible.map((i) => <li key={i.id}>{i.name}</li>)}</ul>
</>
)
}
Why the double requestAnimationFrame
useEffect fires after React commits to the DOM but before the browser has painted. Measuring there gives you React's time and not the browser's. A nested requestAnimationFrame fires after the next paint, so filter-end lands on the frame the user actually sees.
That distinction is the difference between "React took 8ms" and "the user waited 140ms", and the second is the one that matters.
Your measures also show up in the Chrome DevTools Performance panel under Timings, lined up against the flame chart — so you can see exactly which long task ate the frame.
Making the transition fast, not just measured
Once you have a number, the two React APIs that move it:
useDeferredValue — keep the input responsive, let the expensive list lag:
'use client'
import { useState, useDeferredValue, useMemo } from 'react'
export function FilterableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const isStale = query !== deferredQuery
const visible = useMemo(
() => items.filter((i) => i.name.includes(deferredQuery)),
[items, deferredQuery]
)
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul style={{ opacity: isStale ? 0.6 : 1 }}>
{visible.map((i) => <li key={i.id}>{i.name}</li>)}
</ul>
</>
)
}
'use client'
import { useState, useDeferredValue, useMemo } from 'react'
export function FilterableList({ items }) {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const isStale = query !== deferredQuery
const visible = useMemo(
() => items.filter((i) => i.name.includes(deferredQuery)),
[items, deferredQuery]
)
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul style={{ opacity: isStale ? 0.6 : 1 }}>
{visible.map((i) => <li key={i.id}>{i.name}</li>)}
</ul>
</>
)
}
The input updates on every keystroke at full speed. The list updates when React gets a spare moment. Total work is the same; perceived latency drops to near zero.
useTransition — same idea, for explicit actions, with a pending flag:
'use client'
import { useState, useTransition } from 'react'
export function TabPanel() {
const [tab, setTab] = useState('overview')
const [isPending, startTransition] = useTransition()
return (
<>
<nav style={{ opacity: isPending ? 0.6 : 1 }}>
<button onClick={() => startTransition(() => setTab('overview'))}>Overview</button>
<button onClick={() => startTransition(() => setTab('reports'))}>Reports</button>
</nav>
<ExpensivePanel tab={tab} />
</>
)
}
Without startTransition, clicking "Reports" freezes the button until the expensive panel renders. With it, the click registers instantly and the panel swaps when ready.
✅ Render-count assertions in tests
Finding a re-render problem is one thing. Stopping it coming back is another — and that's a test.
The pattern is a counter in a probe component:
// components/Table.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, beforeEach } from 'vitest'
import { Table } from './Table'
let rowRenders = 0
// A probe that counts its own renders
function CountingRow({ row }: { row: Row }) {
rowRenders++
return <li>{row.name}</li>
}
describe('Table', () => {
beforeEach(() => {
rowRenders = 0
})
it('does not re-render rows when the search query changes', async () => {
const user = userEvent.setup()
const rows = Array.from({ length: 50 }, (_, i) => ({ id: String(i), name: `Row ${i}` }))
render(<Table rows={rows} RowComponent={CountingRow} />)
expect(rowRenders).toBe(50) // initial mount
await user.type(screen.getByRole('searchbox'), 'abc')
// 3 keystrokes must not re-render 50 rows 3 times
expect(rowRenders).toBe(50)
})
})
// components/Table.test.js
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, beforeEach } from 'vitest'
import { Table } from './Table'
let rowRenders = 0
function CountingRow({ row }) {
rowRenders++
return <li>{row.name}</li>
}
describe('Table', () => {
beforeEach(() => {
rowRenders = 0
})
it('does not re-render rows when the search query changes', async () => {
const user = userEvent.setup()
const rows = Array.from({ length: 50 }, (_, i) => ({ id: String(i), name: `Row ${i}` }))
render(<Table rows={rows} RowComponent={CountingRow} />)
expect(rowRenders).toBe(50)
await user.type(screen.getByRole('searchbox'), 'abc')
expect(rowRenders).toBe(50)
})
})
This test fails the moment someone reintroduces the bug. Lift the query state back above the rows and it reports:
AssertionError: expected 150 to be +0
Test Files 1 failed (1)
Tests 1 failed (1)
150 = 3 keystrokes × 50 rows. That number is the bug, stated precisely, in CI, before it ships. That's what a performance regression test is for.
Two rules that will bite you
1. StrictMode double-renders in development. React deliberately renders twice to surface impure components. Your counts will double. Either don't wrap tests in StrictMode, or assert on relative change rather than absolutes:
const before = rowRenders
await user.type(input, 'a')
expect(rowRenders - before).toBe(0) // survives StrictMode
Relative assertions are the safer default — they express the intent ("typing must not re-render rows") rather than an implementation detail.
2. Don't assert exact counts on things you don't control. A test that says expect(renders).toBe(7) will break on a React minor upgrade and teach your team to delete performance tests. Assert the property you care about — usually "this did not re-render at all".
Testing hooks in isolation
import { renderHook, act } from '@testing-library/react'
import { useFilteredItems } from './useFilteredItems'
it('returns a stable reference when the query is unchanged', () => {
const items = [{ id: '1', name: 'a' }]
const { result, rerender } = renderHook(({ q }) => useFilteredItems(items, q), {
initialProps: { q: '' },
})
const first = result.current
rerender({ q: '' })
expect(result.current).toBe(first) // same reference → memo downstream works
})
Reference stability is what makes memo downstream effective, and it's directly testable.
🎭 Performance budgets in Playwright
Unit tests catch re-render regressions. Playwright catches "the interaction got slow" regressions, in a real browser.
// e2e/performance.spec.ts
import { test, expect } from '@playwright/test'
test('filtering 1000 rows stays under 100ms', async ({ page }) => {
await page.goto('/dashboard')
await page.waitForSelector('[data-testid="row"]')
const duration = await page.evaluate(async () => {
const input = document.querySelector<HTMLInputElement>('input[type="search"]')!
performance.mark('start')
input.value = 'widget'
input.dispatchEvent(new Event('input', { bubbles: true }))
// wait for two frames so the paint is included
await new Promise<void>((r) =>
requestAnimationFrame(() => requestAnimationFrame(() => r()))
)
performance.mark('end')
return performance.measure('filter', 'start', 'end').duration
})
expect(duration).toBeLessThan(100)
})
test('no long tasks during navigation', async ({ page }) => {
await page.goto('/')
const longTasks = await page.evaluate(async () => {
const tasks: number[] = []
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) tasks.push(entry.duration)
}).observe({ type: 'longtask', buffered: true })
document.querySelector<HTMLAnchorElement>('a[href="/module/nextjs"]')!.click()
await new Promise((r) => setTimeout(r, 3000))
return tasks
})
// Nothing should block the main thread for more than 200ms
expect(Math.max(0, ...longTasks)).toBeLessThan(200)
})
// e2e/performance.spec.js
import { test, expect } from '@playwright/test'
test('filtering 1000 rows stays under 100ms', async ({ page }) => {
await page.goto('/dashboard')
await page.waitForSelector('[data-testid="row"]')
const duration = await page.evaluate(async () => {
const input = document.querySelector('input[type="search"]')
performance.mark('start')
input.value = 'widget'
input.dispatchEvent(new Event('input', { bubbles: true }))
await new Promise((r) =>
requestAnimationFrame(() => requestAnimationFrame(() => r()))
)
performance.mark('end')
return performance.measure('filter', 'start', 'end').duration
})
expect(duration).toBeLessThan(100)
})
Set the budget generously. CI machines are slow, noisy, and shared. A budget of 100ms on a 20ms interaction catches real regressions; a budget of 25ms produces a flaky test that gets deleted in a month.
Run these on a production build — Chapter 23's rule applies doubly here. Dev-mode numbers are meaningless.
📊 Lighthouse CI
Component-level budgets catch component-level regressions. Lighthouse CI catches "someone imported moment.js into the homepage".
npm install -D @lhci/cli
// lighthouserc.js
module.exports = {
ci: {
collect: {
startServerCommand: 'npm run start',
url: ['http://localhost:3000/', 'http://localhost:3000/module/nextjs'],
numberOfRuns: 3,
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-blocking-time': ['error', { maxNumericValue: 300 }],
'unused-javascript': ['warn', { maxLength: 1 }],
},
},
upload: { target: 'temporary-public-storage' },
},
}
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: pull_request
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm run build
- run: npx lhci autorun
numberOfRuns: 3 matters — Lighthouse is noisy, and it takes the median. A single run on a CI runner will fail randomly.
🐢 Debugging INP
Interaction to Next Paint is the Core Web Vital for responsiveness: from the user's click to the frame that reflects it. Under 200ms is good.
When INP is bad, you need to find the long task behind it. The Long Animation Frames API does that:
// app/components/LoAFMonitor.tsx
'use client'
import { useEffect } from 'react'
export function LoAFMonitor() {
useEffect(() => {
if (!PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
return
}
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const frame = entry as PerformanceEntry & {
blockingDuration: number
scripts: Array<{
name: string
duration: number
sourceURL: string
sourceFunctionName: string
}>
}
if (frame.blockingDuration < 50) continue
console.warn(`Long frame: ${frame.duration.toFixed(0)}ms`)
for (const script of frame.scripts) {
console.warn(
` ${script.sourceFunctionName || '(anonymous)'} ` +
`${script.duration.toFixed(0)}ms — ${script.sourceURL}`
)
}
}
})
observer.observe({ type: 'long-animation-frame', buffered: true })
return () => observer.disconnect()
}, [])
return null
}
// app/components/LoAFMonitor.js
'use client'
import { useEffect } from 'react'
export function LoAFMonitor() {
useEffect(() => {
if (!PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
return
}
const observer = new PerformanceObserver((list) => {
for (const frame of list.getEntries()) {
if (frame.blockingDuration < 50) continue
console.warn(`Long frame: ${frame.duration.toFixed(0)}ms`)
for (const script of frame.scripts) {
console.warn(
` ${script.sourceFunctionName || '(anonymous)'} ` +
`${script.duration.toFixed(0)}ms — ${script.sourceURL}`
)
}
}
})
observer.observe({ type: 'long-animation-frame', buffered: true })
return () => observer.disconnect()
}, [])
return null
}
The scripts array is the payoff — it names the actual function and source file that blocked the frame. That's a level of detail the older longtask entry type never gave you.
For real-user INP data, the web-vitals library attributes it for you:
'use client'
import { onINP } from 'web-vitals/attribution'
import { useEffect } from 'react'
export function INPReporter() {
useEffect(() => {
onINP((metric) => {
navigator.sendBeacon('/api/vitals', JSON.stringify({
value: metric.value,
rating: metric.rating,
target: metric.attribution.interactionTarget, // the CSS selector clicked
type: metric.attribution.interactionType,
inputDelay: metric.attribution.inputDelay,
processingDuration: metric.attribution.processingDuration,
presentationDelay: metric.attribution.presentationDelay,
}))
})
}, [])
return null
}
Those three sub-parts tell you which problem you have:
| Dominant phase | Meaning | Fix |
|---|---|---|
inputDelay | The main thread was already busy | Reduce work on load; defer third-party scripts |
processingDuration | Your handler + render is slow | This chapter — profile the render |
presentationDelay | Rendering/painting the result is slow | Simplify the DOM; virtualize; reduce layout thrash |
🚫 What not to measure
Getting this wrong wastes days.
Development mode. Dev builds are unoptimized, include warnings and dev-only checks, and compile on demand. A component that takes 40ms in next dev can take 3ms in production. Every number in this chapter must come from next build && next start.
StrictMode double renders. In development React renders components twice on purpose. That's not a bug and it doesn't happen in production — but it will make your render counts look twice as bad.
A single sample. Run an interaction ten times and take the median. The first run includes JIT warm-up and cold caches.
Your laptop. Enable CPU throttling (4×–6× slowdown) in DevTools. Your users are on cheaper hardware than you.
Micro-benchmarks. "This function runs in 0.02ms" tells you nothing about whether the app feels fast. Measure interactions, not functions.
Things that aren't the bottleneck. If your page ships 900KB of JavaScript, no amount of useMemo will help. Chapter 22 comes first; this chapter is for when the bundle is already lean and it still feels slow.
⚠️ Common Pitfalls
1. Profiling in development and shipping the "fix"
The most common wasted afternoon. Always confirm the problem exists in a production build before optimizing it.
2. Memoizing everything
// ❌ all cost, no benefit
const value = useMemo(() => a + b, [a, b])
const handler = useCallback(() => setOpen(true), [])
useMemo stores a value and compares dependencies on every render. For cheap computations that's a net loss. Measure the baseDuration/actualDuration ratio before adding any, or turn on the React Compiler and delete them all.
3. Memoizing a component whose props change every render
const Row = memo(Row) // looks optimized
<Row onSelect={() => select(id)} /> // new function every render → memo never hits
why-did-you-render catches this in seconds. So does DevTools with "why did this render" enabled.
4. Measuring in useEffect and calling it "time to paint"
useEffect runs before the browser paints. Use the double requestAnimationFrame if you want the number the user experiences.
5. Asserting exact render counts
expect(renders).toBe(7) breaks on a React upgrade. Assert the property — "typing must not re-render the rows" — with a relative comparison.
6. Shipping profiling tools to production
why-did-you-render patches React itself. React Scan is a third-party script. Both are development-only; guard them with process.env.NODE_ENV.
7. Tight CI performance budgets
A budget within 2× of the real value will flake. Flaky performance tests get disabled, and then you have no performance tests.
8. Profiling Server Components
They render once, on the server, and never re-render. There's nothing for the Profiler to find. If a page feels slow and it's all Server Components, your problem is data fetching or payload size — Chapters 11 and 22.
9. Optimizing renders when the real cost is the DOM
5,000 rows will be slow no matter how few times React re-renders them, because the browser has to lay out 5,000 elements. Virtualize with @tanstack/react-virtual or paginate. No memoization fixes a DOM that large.
🎯 When & Why to Use
React Scan when:
✅ First look — "what's re-rendering?" (10 seconds, no setup)
React DevTools Profiler when:
✅ You know where, need to know why and how much
✅ Reading "why did this render?" for a specific component
<Profiler> when:
✅ Measuring a subtree programmatically
✅ Checking whether memoization actually pays (actual vs base)
✅ Sampling real users in production
why-did-you-render when:
✅ "It re-renders and I cannot work out which prop changed"
performance.mark/measure when:
✅ Timing a specific interaction end to end, including paint
Render-count tests when:
✅ You fixed a re-render bug and want it to stay fixed
Playwright budgets when:
✅ Guarding an interaction's latency in CI
Lighthouse CI when:
✅ Guarding page-level metrics on every PR
None of them when:
❌ You haven't confirmed there's a problem in production
❌ The bundle is the bottleneck (Chapter 22 first)
❌ The page is all Server Components
🏋️ Mini Practice Problems
Problem 1: Read the numbers
<Profiler> reports these for one commit. What do you conclude in each case?
- A.
baseDuration: 4ms, actualDuration: 4ms - B.
baseDuration: 210ms, actualDuration: 205ms - C.
baseDuration: 180ms, actualDuration: 6ms - D.
baseDuration: 3ms, actualDuration: 9ms
Problem 2: Find the re-render
React Scan outlines all 300 rows when the user types. DevTools says every row re-rendered "because the parent component rendered". Row is already wrapped in memo.
'use client'
export function Table({ rows }: { rows: Row[] }) {
const [query, setQuery] = useState('')
const config = { striped: true, dense: false }
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{rows.map((row) => (
<Row key={row.id} row={row} config={config} onSelect={() => select(row.id)} />
))}
</>
)
}
Name both reasons memo isn't working, then give the fix you'd prefer and say why.
Problem 3: Write the regression test
You've just fixed Problem 2. Write the Vitest test that fails if someone reintroduces either bug. Make it survive StrictMode.
Problem 4: Diagnose from INP attribution
Three users report a laggy "Add to cart" button. The web-vitals attribution says:
- User A:
inputDelay: 180ms, processingDuration: 20ms, presentationDelay: 15ms - User B:
inputDelay: 10ms, processingDuration: 240ms, presentationDelay: 20ms - User C:
inputDelay: 15ms, processingDuration: 30ms, presentationDelay: 310ms
For each: what's the likely cause, and which chapter of this book has the fix?
💼 Interview Notes
Common Questions
Q: How do you find out why a React component re-renders?
React Scan for a quick visual answer, then the React DevTools Profiler with "record why each component rendered" enabled — it reports whether props changed, hooks changed, or the parent simply re-rendered. why-did-you-render gives a prop-level diff when the cause isn't obvious.
Q: What's the difference between actualDuration and baseDuration in <Profiler>?
baseDuration is what the subtree would cost with no memoization; actualDuration is what it actually cost. The ratio tells you whether your memoization is earning its keep — if they're nearly equal on an expensive tree, you're paying comparison costs for nothing.
Q: How would you measure how long a state transition takes?
performance.mark before the state update, then a nested double requestAnimationFrame inside a useEffect to mark after paint, then performance.measure between them. The double rAF matters — useEffect runs before the browser paints, so measuring there reports React's time, not the user's.
Q: How do you keep an expensive filter from blocking typing?
useDeferredValue on the query so the input updates immediately and the list catches up, or useTransition for explicit actions where you also want a pending state. The total work is unchanged; what changes is that React can interrupt the low-priority render to keep the input responsive.
Q: How do you write a test that a component doesn't re-render unnecessarily? Render with a probe component that increments a counter, capture the count, perform the interaction, then assert the count didn't change. Use a relative assertion rather than an absolute number so StrictMode and React upgrades don't break it.
Q: Why can't you trust performance numbers from next dev?
Development builds are unoptimized, include dev-only warnings and checks, compile routes on demand, and StrictMode double-renders. Numbers can be an order of magnitude off. Always measure next build && next start.
Q: A page has a bad INP score. How do you diagnose it?
Use web-vitals/attribution to split INP into input delay, processing duration, and presentation delay. High input delay means the main thread was already busy on load; high processing means your handler and render are slow; high presentation means the DOM update is expensive. The Long Animation Frames API names the exact script and function that blocked the frame.
Q: When is render profiling the wrong thing to be doing? When the bundle is the bottleneck, when the page is all Server Components (they never re-render), or when the real cost is the size of the DOM rather than the number of renders.
🏢 Asked at Companies
- Meta: "A list re-renders on every keystroke despite
memo. Walk me through diagnosing it." - Vercel: "How would you prove a performance fix worked, and stop it regressing?"
- Linear: "Our editor feels laggy at 5,000 nodes but the bundle is small. Where do you look?"
- Stripe: "Explain
actualDurationvsbaseDurationand what you'd do with the ratio." - Airbnb: "Design a performance budget for a checkout page and say how you'd enforce it in CI."
📊 Visual Memory Aid
TWO KINDS OF SLOW
DOWNLOAD (Chapter 22) RUNTIME (this chapter)
────────────────────── ──────────────────────
bundle size re-render count
waterfalls render cost
image weight main-thread blocking
↓ measured with ↓ measured with
Lighthouse, analyzer Profiler, React Scan
THE TOOL LADDER
1. React Scan "what re-renders?" 10 sec, no setup
2. DevTools Profiler "why, and how much?" interactive
3. <Profiler> "measure this subtree" programmatic
4. why-did-you-render "which prop changed?" dev only
5. performance.mark "how long is this?" any interaction
6. render-count test "keep it fixed" CI
7. Lighthouse CI "keep the page fast" CI
actualDuration vs baseDuration
base 180ms │████████████████████│ no memoization
actual 6ms │█│ memo is working ✅
base 210ms │██████████████████████│
actual 205ms │█████████████████████│ memo does NOTHING ❌
(props change every render)
MEASURING TO PAINT
onChange → performance.mark('start')
↓
React renders
↓
React commits
↓
useEffect ← ❌ measuring here misses the paint
↓
rAF
↓
rAF ← ✅ measure here: the frame the user sees
↓
browser paints
INP ATTRIBUTION → CULPRIT
inputDelay high → main thread busy → Ch 22
processingDuration high → your render is slow → Ch 27 (here)
presentationDelay high → DOM too big/complex → virtualize
🎯 Key Takeaways
- Bundle size and render performance are different problems with different tools. Chapter 22 is about what downloads; this is about what happens after — and Server Components have no render problem at all, because they never re-render.
- React Scan first, DevTools Profiler second. Ten seconds of visual feedback tells you where to look; "why did this render?" tells you why. The answer is usually "the parent component rendered".
baseDurationvsactualDurationis the memoization report card. Nearly equal on an expensive tree means you're paying formemoand getting nothing — almost always an inline function or object prop.- Measure interactions to paint, not to commit.
useEffectfires before the browser paints; a nested doublerequestAnimationFramegives you the number the user actually experiences. - Turn every fix into a test. A render-count assertion with a relative comparison, plus a generous Playwright budget, is what stops the regression coming back — and generous budgets are the ones that survive.
Next Chapter: None — you've reached the end. Back to the Table of Contents →
Practice: Take the slowest interactive page in an app you own. Run React Scan on it, find the widest re-render, fix it with state-lowering rather than memoization, and write the render-count test that keeps it fixed. Measure before and after with performance.measure, on a production build, with 4× CPU throttling.