Optimizing Performance in React: The Complete Guide
Everything you need to know about React performance—from profiling tools to virtualization, memoization, and Web Workers. With code examples and breakdowns of common errors.
From the job posting: "Understanding of performance optimization techniques in React (lazy loading, memoization, virtualization)."
High-load web app, mobile-first - performance is not optional, it's a requirement.
Interviewers will ask about real cases: "Your feed with 10k messages is lagging — what do you do?"
Checklist
5.1. Rule Number One: Measure First
The most costly mistake is optimizing by gut feeling. Wrapping everything in useMemo, scattering useCallback across components — that's not optimization, it's the appearance of work. Find the bottleneck first, then fix it precisely.
Diagnostic Tools
React DevTools Profiler
- Open the Profiler tab in React DevTools
- Record the problematic interaction — study the flamegraph
- Color = render time: yellow (slow) → gray (didn't render at all)
- In settings, enable "Highlight updates when components render" — see everything that flickers
// Programmatic access to profiler data:
import { Profiler } from 'react';
<Profiler id="Feed" onRender={(id, phase, actualDuration) => {
console.log({ id, phase, actualDuration });
}}>
<Feed />
</Profiler>Chrome Performance
- DevTools → Performance → Record
- Reproduce the problematic action → Stop
- Look for:
Bundle Analyzer
# Vite
npm i -D rollup-plugin-visualizer
# Webpack
npm i -D webpack-bundle-analyzerThe treemap shows what's actually taking up space in your bundle. Surprise: often 40% is the entire lodash, moment.js with all locales, or the full @mui/icons-material.
5.2. Core Web Vitals — The Metrics That Matter
How to Improve Each Metric
LCP — "Make the page feel loaded quickly"
- Hero image:
<link rel="preload" as="image" href="..."> <img fetchpriority="high">for the key image- Fonts:
font-display: swap+ preload - CDN for static assets, SSR/SSG for first paint
- Reduce JS on the critical path
INP — "Make the page feel alive"
- Eliminate long tasks from handlers (debounce, defer, split up)
startTransition/useDeferredValuefor non-urgent updates- Heavy computations → Web Worker
scheduler.postTask()/scheduler.yield()in modern browsers
CLS — "Make sure buttons don't run away from the cursor"
- Always set
width/heighton<img>(oraspect-ratio) - Reserve space for dynamic content via
min-height, skeletons - Don't inject banners into the DOM after main content without reserved space
Measuring in Code
import { onCLS, onINP, onLCP } from 'web-vitals';
onLCP(console.log);
onINP(console.log);
onCLS(console.log);
// Send to your analytics — not just console.log in prod5.3. Causes of Unnecessary Re-renders — Breaking It Down
1. New reference in props of a React.memo component
const Child = React.memo(ChildImpl);
function Parent() {
// memo is useless — every render creates a new object and function
return <Child config={{ size: 10 }} onClick={() => doStuff()} />;
}
// Fix:
const config = useMemo(() => ({ size: 10 }), []);
const handleClick = useCallback(() => doStuff(), []);
<Child config={config} onClick={handleClick} />2. Context provider with a new value on every render
// Bad — object recreated on every render
<UserCtx.Provider value={{ user, setUser }}>
// Good — stable reference
const value = useMemo(() => ({ user, setUser }), [user]);
<UserCtx.Provider value={value}>3. State too high in the tree
If modal state lives in App, the entire App re-renders on every open. Move state down to where it's actually needed.
4. Objects and arrays in hook deps
// Every render = new array → effect fires every time
useEffect(() => { ... }, [filters]);
// Better: decompose to primitives
useEffect(() => { ... }, [filters.status, filters.search]);5. Unstable keys in lists
// Catastrophic
items.map((item, i) => <Row key={`${i}-${Math.random()}`} />)
// Bad for insertions in the middle
items.map((item, i) => <Row key={i} />)
// Correct
items.map(item => <Row key={item.id} />)5.4. React.memo, useMemo, useCallback — When They're Actually Needed
These tools are not free. React still creates functions, compares deps, stores in memory. For cheap computations, the overhead costs more than the savings.
useMemo — when it's justified
// 1. Expensive computation
const sortedUsers = useMemo(
() => users.slice().sort(complexCompareFn),
[users]
);
// 2. Stable reference for a memo component below
const config = useMemo(() => ({ size: 10, mode: 'auto' }), []);
<ExpensiveChart config={config} />
// 3. Stable reference as a dependency of another hook
const options = useMemo(() => ({ retry: 3 }), []);
useEffect(() => fetchWith(options), [options]);useCallback — when it's justified
// Only when the function is passed to a React.memo component
// or is a dependency of another hook
const Child = React.memo(ChildImpl);
const handleClick = useCallback(() => setCount(c => c + 1), []);
<Child onClick={handleClick} />React.memo — when it helps
- Component is expensive to render
- Parent renders often, props rarely change
- Props are primitives or stable references
// Custom comparator (rarely needed)
const Row = React.memo(RowImpl, (prev, next) => (
prev.id === next.id && prev.name === next.name
));
// Returns true = "equal, do NOT re-render" — opposite of shouldComponentUpdate, don't confuse themAnti-patterns — what not to do
// 1. Memo without measurement — an illusion of optimization
const Button = React.memo(({ onClick, children }) => (
<button onClick={onClick}>{children}</button>
));
// If children = <Icon /> — new React element every time → memo is useless
// 2. useCallback without memo children
const handler = useCallback(() => setX(1), []);
return <div onClick={handler} />; // div re-renders regardless
// 3. Memoizing cheap computations
const sum = useMemo(() => a + b, [a, b]); // overhead > savings5.5. List Virtualization
Problem: 10,000 rows = 10,000 DOM nodes. Expensive mount, blocked main thread. Only ~20 visible on screen at a time.
Solution: render only visible items + a small overscan buffer at the edges.
Choosing a Library
- TanStack Virtual — modern headless choice, maximum flexibility
- react-window — simple, for fixed/variable row heights
- react-virtuoso — better for chats and feeds, handles unknown heights out of the box
import { useVirtualizer } from '@tanstack/react-virtual';
function BigList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64, // estimated row height
overscan: 5, // render +5 rows above/below for smoothness
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((v) => (
<div
key={v.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: v.size,
transform: `translateY(${v.start}px)`,
}}
>
{items[v.index].name}
</div>
))}
</div>
</div>
);
}Key gotchas:
overflow: autois required on the scroll containerposition: absolute+transformfor rows (faster thantop/left)- If height is unknown —
measureElementrecalculates after render - For chats with reverse scroll — Virtuoso is more convenient
5.6. Code Splitting
By Route — the baseline
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>By Component — for heavy widgets
const MarkdownEditor = lazy(() => import('./MarkdownEditor'));
{showEditor && (
<Suspense fallback={<EditorSkeleton />}>
<MarkdownEditor />
</Suspense>
)}By Library — for rare operations
async function exportToPdf(data) {
const { jsPDF } = await import('jspdf'); // loads only when "Export" is clicked
const doc = new jsPDF();
// ...
}Preload on Hover
const Dashboard = lazy(() => import('./Dashboard'));
<Link
to="/dashboard"
onMouseEnter={() => {
import('./Dashboard'); // browser starts fetching the chunk early
}}
>
Dashboard
</Link>Handling ChunkLoadError
const Dashboard = lazy(() =>
import('./Dashboard').catch(() => ({
default: () => <div>Failed to load. Please refresh the page.</div>,
}))
);Common deploy bug: the user has the old page open, a new deploy removed old chunks — dynamic import throws ChunkLoadError. Catch it in an Error Boundary and offer a reload.5.7. Bundle Size — Hunting Down Dead Weight
Classic Culprits
lodash
// Bad: pulls in the entire lodash
import _ from 'lodash';
_.debounce(fn, 300);
// Good: only what you need
import debounce from 'lodash/debounce';
// Or the ESM version — better for tree shaking
import { debounce } from 'lodash-es';moment.js → date-fns or dayjs
moment.js is frozen, heavy, and bundles all locales. date-fns is tree-shakeable, dayjs is a lightweight drop-in replacement.
Icon Libraries
// Bad: entire @mui/icons-material
import { Delete } from '@mui/icons-material';
// Good: just the icon you need
import Delete from '@mui/icons-material/Delete';What Breaks Tree Shaking
- CommonJS dependencies — bundler can't analyze statically
- Side effects in modules — need
"sideEffects": falseinpackage.json import * as X from 'mod'— named imports work better- Code with side effects at module top level (polyfills, prototype patches)
Power move: block PRs in CI that grow the bundle by more than N% — use size-limit.5.8. Lazy Loading Images and Resource Hints
Native Lazy Loading
<img src="photo.jpg" loading="lazy" decoding="async" width="800" height="600" />
<!-- width/height are required — otherwise CLS -->srcset for Retina and Mobile
<img
src="photo-800.jpg"
srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1600.jpg 1600w"
sizes="(max-width: 600px) 100vw, 50vw"
alt="..."
/>Resource Hints — Hints for the Browser
<link rel="preconnect" href="https://api.example.com"> <!-- DNS+TCP+TLS early -->
<link rel="dns-prefetch" href="https://cdn.example.com"> <!-- DNS only -->
<link rel="preload" as="font" href="/fonts/inter.woff2" crossorigin>
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
<link rel="prefetch" href="/next-page.js"> <!-- low priority, for future navigation -->Image Formats
- AVIF — best compression, supported in modern browsers
- WebP — universal choice (30% lighter than JPEG)
- JPEG/PNG — fallback
<picture>
<source srcset="hero.avif" type="image/avif" />
<source srcset="hero.webp" type="image/webp" />
<img src="hero.jpg" alt="..." width="1200" height="600" />
</picture>5.9. Web Workers — Move the Heavy Lifting Off the Main Thread
When to use: CPU-intensive tasks — parsing large JSON, compression, sorting millions of elements, cryptography, diff algorithms, image processing.
// main.js
const worker = new Worker(new URL('./heavy.worker.ts', import.meta.url), { type: 'module' });
worker.postMessage({ data: bigArray });
worker.onmessage = (e) => setResult(e.data);
// heavy.worker.ts
self.onmessage = (e) => {
const result = heavyComputation(e.data.data);
self.postMessage(result);
};Comlink — Clean RPC over a Worker
// worker.ts
import * as Comlink from 'comlink';
Comlink.expose({
heavy: (data) => heavyComputation(data),
});
// main.ts
import * as Comlink from 'comlink';
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
const api = Comlink.wrap(worker);
const result = await api.heavy(data); // looks just like a regular awaitLimitations
- No access to DOM/window (but
self,fetch,WebSocketare available) - Exchange via
postMessage— data is cloned, large transfers are expensive - Transferable Objects (
ArrayBuffer) — transferred without copying SharedArrayBufferrequires COOP/COEP headers
5.10. Debounce and Throttle in React — Hidden Pitfalls
// DOESN'T WORK as expected
function Search() {
const [value, setValue] = useState('');
const debounced = debounce((v) => fetch(v), 300);
// Every render = NEW debounce function, the internal timer is lost
return <input onChange={(e) => debounced(e.target.value)} />;
}
// Correct #1: useMemo
const debounced = useMemo(() => debounce(fetch, 300), []);
useEffect(() => debounced.cancel, [debounced]); // cancel on unmount
// Correct #2 (better): debounce the value, not the call
function Search() {
const [value, setValue] = useState('');
const debouncedValue = useDebouncedValue(value, 300);
useEffect(() => { if (debouncedValue) fetch(debouncedValue); }, [debouncedValue]);
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}5.11. useTransition and useDeferredValue
When it helps: there's an urgent update (typing into an input) and expensive derived work (filtering 10k rows).
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const results = useMemo(
() => filterBig(items, deferredQuery),
[items, deferredQuery]
);
// Input stays responsive — the heavy list updates later and can be interruptedDifference:
useTransition— you explicitly mark an update as non-urgentuseDeferredValue— reader model: "react to the deferred version of this value"
5.12. Memory Leaks — Where the Leaks Hide
Timers and Subscriptions Without Cleanup
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id); // REQUIRED
}, []);
useEffect(() => {
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);WebSocket / EventSource
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = handle;
return () => ws.close();
}, [url]);Closures Holding Large State
function makeHandler() {
const hugeArray = new Array(1e6).fill(0);
const small = hugeArray[0];
return () => small; // closed over the entire scope — hugeArray won't be freed
}Finding Leaks in DevTools
- Memory → Heap Snapshot
- Take Snapshot 1
- Repeat the suspicious action N times (open/close a modal)
- Snapshot 2 → Comparison view
- Look at #Delta — which objects increased abnormally
In Performance monitor, watch JS heap size — if it grows monotonically, that's a leak.
5.13. Layout Thrashing — Why the Browser Slows Down
Reflow — layout recalculation. Expensive. Repaint — redraw without layout. Cheaper.
Forced Synchronous Layout
// Bad — read after write → reflow on every iteration
for (const el of elements) {
el.style.width = el.offsetWidth + 10 + 'px';
}
// Good — all reads first, then all writes
const widths = elements.map(el => el.offsetWidth);
elements.forEach((el, i) => { el.style.width = widths[i] + 10 + 'px'; });Properties That Trigger Reflow on Read
offsetWidth/Height/Top/Left, clientWidth/Height, scrollWidth/Height/Top/Left, getBoundingClientRect(), getComputedStyle(), innerWidth/Height
Animations Without Reflow — Use the GPU
/* Bad — triggers reflow */
.moving { transition: left 0.3s; }
/* Good — compositing only */
.moving { transition: transform 0.3s; }
/* Hint to the browser: "I'll be animating transform" */
.moving { will-change: transform; }
/* Don't sprinkle will-change everywhere — it consumes GPU memory */5.14. Caching
HTTP
Cache-Control: max-age=31536000, immutable— for hashed files (app.abc123.js)Cache-Control: no-cache— always revalidate (typical forindex.html)ETag/Last-Modified— conditional requests (304 Not Modified)stale-while-revalidate— serve stale and quietly refresh in background
Service Worker and PWA
- Strategies: cache-first, network-first, stale-while-revalidate
- Workbox — convenient abstraction over all of this
In-Memory for API
- TanStack Query / SWR / RTK Query — request deduplication, invalidation, stale-while-revalidate
- Better than a manual Redux cache for server state