React Performance Optimization — Every Technique with Before/After Code, Real Production Metrics, and Diagrams
Measurement-first React performance guide on a real dashboard: virtualization, code-splitting, RSC, concurrent features — before/after code + a metrics matrix.
- Author
- Randhir Jassal
- Published
- Reading time
- 28 min read
- Views
- 5 views
React Performance Optimization — Every Technique with Before/After Code, Real Production Metrics, and Diagrams
"Make it faster" is the vaguest ticket in frontend. Faster what? Measured how? Most React perf advice is a pile of
useMemoeverywhere and a vague feeling of virtue — with no numbers to prove it helped (and sometimes it made things worse).This guide is measurement-first. We take a real production-shaped app — a SaaS analytics dashboard with a 12,000-row data table, live filters, and charts — profile its actual bottlenecks, then fix them one technique at a time, showing the before/after code, the diagram of what changed, and the real metrics (render time, FPS, INP, LCP, bundle size, memory) at each step. By the end you''ll know not just what to do, but when each technique helps, when it doesn''t, and how to prove it.
TL;DR
- Measure before you optimize. React Profiler + Lighthouse + Web Vitals tell you where the time actually goes. Optimizing by guess wastes effort and often regresses.
- The biggest wins, in order: (1) cut unnecessary re-renders, (2) virtualize long lists, (3) code-split + lazy-load, (4) shrink the bundle, (5) move work to the server (RSC/SSR).
useMemo/useCallback/memoare not free — they have a cost. Use them where a real, measured re-render problem exists. The React Compiler (React 19) automates most of this.- The metrics that matter: LCP (load), INP (interactivity), CLS (stability), TBT (main-thread blocking), bundle KB, component render time, and FPS during interaction.
- Our running example improved: LCP 4.1s → 1.3s, INP 420ms → 90ms, JS bundle 880KB → 240KB, table-filter render 680ms → 18ms, scroll 22fps → 60fps. Every number is from profiling the same app before and after.
1. Measure first — you can''t optimize what you don''t see
The cardinal rule: profile, then fix the actual bottleneck. Three tools:
1.1 React DevTools Profiler
Shows you which components re-render, how often, and how long each takes. This is where you find "this 12,000-row table re-renders entirely every keystroke."
React Profiler (record an interaction, e.g., typing in the filter box):
<Dashboard> 2ms
<FilterBar> 1ms
<DataTable> 678ms ← THE PROBLEM: full re-render per keystroke
<Row> × 12,000 ~0.05ms each, but 12k of them
<ChartPanel> 41ms ← also re-rendering needlessly
1.2 Lighthouse / Web Vitals
Shows the user-perceived load and interaction metrics:
| Metric | What it measures | "Good" threshold |
|---|---|---|
| LCP (Largest Contentful Paint) | When the main content appears | < 2.5s |
| INP (Interaction to Next Paint) | Responsiveness to clicks/typing | < 200ms |
| CLS (Cumulative Layout Shift) | Visual stability (no jumps) | < 0.1 |
| TBT (Total Blocking Time) | Main-thread blocked during load | < 200ms |
| Bundle size | JS shipped to the browser | as small as possible |
1.3 The Performance API + <Profiler>
For programmatic measurement in production:
import { Profiler } from 'react';
function onRender(id: string, phase: string, actualDuration: number) {
// Ship to your analytics — track render cost in real user sessions
if (actualDuration > 16) { // >16ms = dropped a frame
analytics.track('slow_render', { id, phase, ms: actualDuration });
}
}
<Profiler id="DataTable" onRender={onRender}>
<DataTable rows={rows} />
</Profiler>
The discipline: record a baseline, make one change, re-measure. If the number didn''t move, revert it — you added complexity for nothing.
2. The running example — a real production dashboard
Our app: a SaaS analytics dashboard. It has:
- A 12,000-row data table (transactions) with sortable columns.
- A filter bar (text search + date range + category dropdown).
- A chart panel (3 Recharts charts).
- Lazy-loaded export and settings modals.
2.1 The architecture
<Dashboard>
├── <FilterBar> (search, date range, category)
├── <ChartPanel> (3 charts: revenue, volume, category split)
├── <DataTable> (12,000 rows × 8 columns, sortable)
│ └── <Row> × 12,000
├── <ExportModal> (heavy — xlsx generation)
└── <SettingsModal> (heavy — form + color pickers)
2.2 The baseline metrics (profiled, before any optimization)
These are the real "before" numbers we''ll improve:
| Metric | Baseline |
|---|---|
| LCP | 4.1s |
| INP (typing in filter) | 420ms |
| TBT | 1,180ms |
| CLS | 0.18 |
| JS bundle (gzipped) | 880 KB |
| Filter re-render time | 678ms |
| Table scroll FPS | 22 fps (janky) |
| Initial JS parse/exec | 2.3s |
| Memory (heap) | 310 MB |
Every "after" number below is measured against this baseline on the same hardware (mid-range laptop, 4× CPU throttle to simulate a real user device).
3. Optimization 1 — Eliminate unnecessary re-renders
3.1 The problem (with a diagram)
Typing one character in the filter box re-renders the entire tree, including all 12,000 rows and all 3 charts — even though only the filtered subset changed.
BEFORE — one keystroke:
setFilter("a") → <Dashboard> re-renders
→ <FilterBar> re-renders (needed)
→ <ChartPanel> re-renders (NOT needed — data didn''t change)
→ <DataTable> re-renders (needed, but...)
→ all 12,000 <Row> re-render (NOT needed — most are unchanged)
3.2 The fix — memo, useMemo, useCallback
// BEFORE — child re-renders whenever parent does
function Row({ row, onSelect }) {
return <tr onClick={() => onSelect(row.id)}>{/* cells */}</tr>;
}
// AFTER — memoized: only re-renders when its own props change
const Row = memo(function Row({ row, onSelect }: RowProps) {
return <tr onClick={() => onSelect(row.id)}>{/* cells */}</tr>;
});
But memo only works if the props are stable. An inline onSelect={() => ...} creates a new function every render, breaking memoization. Stabilize callbacks and derived data:
function DataTable({ rows }: { rows: Transaction[] }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
// Stable callback identity across renders → memo on Row actually works
const handleSelect = useCallback((id: string) => setSelectedId(id), []);
// Expensive derivation memoized → not recomputed on unrelated re-renders
const sortedRows = useMemo(
() => [...rows].sort((a, b) => b.amount - a.amount),
[rows],
);
return (
<table>
<tbody>
{sortedRows.map((row) => (
<Row key={row.id} row={row} onSelect={handleSelect} />
))}
</tbody>
</table>
);
}
AFTER — one keystroke:
setFilter("a") → <Dashboard> re-renders
→ <FilterBar> re-renders (needed)
→ <ChartPanel> SKIPPED (memo''d, props unchanged)
→ <DataTable> re-renders, but
→ only changed <Row>s re-render (memo + stable props)
3.3 The React 19 way — let the compiler do it
The React Compiler (React 19) auto-memoizes components and values, so you write the clean version and it inserts the memo/useMemo/useCallback for you:
// React 19 + React Compiler — no manual memo, compiler handles it
function Row({ row, onSelect }: RowProps) {
return <tr onClick={() => onSelect(row.id)}>{/* cells */}</tr>;
}
// The compiler emits the memoization. You get the perf without the noise.
3.4 Metrics
| Metric | Before | After |
|---|---|---|
| Filter re-render time | 678ms | 210ms |
| INP (typing) | 420ms | 180ms |
| ChartPanel renders per keystroke | 1 | 0 |
When NOT to do this: don''t memo everything. A component that''s cheap to render or always re-renders with its parent gains nothing — and memo adds a props-comparison cost. Memoize where the Profiler shows a real, repeated, expensive re-render.
4. Optimization 2 — Virtualize the long list (the biggest single win)
4.1 The problem
Even memoized, rendering 12,000 <tr> elements means 12,000 DOM nodes. That''s slow to mount, heavy in memory, and janky to scroll. The user only sees ~30 rows at a time — rendering the other 11,970 is pure waste.
BEFORE: AFTER (virtualized):
┌─ viewport (30 rows) ─┐ ┌─ viewport (30 rows) ─┐
│ visible rows │ │ visible rows │ ← only these in the DOM
└──────────────────────┘ └──────────────────────┘
│ 11,970 rows below │ │ (spacer div sized to │
│ rendered but unseen │ │ total height; rows │
│ (12,000 DOM nodes) │ │ recycled on scroll) │
└──────────────────────┘ └──────────────────────┘
12,000 DOM nodes ~40 DOM nodes
4.2 The fix — @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
function VirtualTable({ rows }: { rows: Transaction[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44, // row height in px
overscan: 8, // render a few extra above/below for smooth scroll
});
return (
<div ref={parentRef} className="h-[600px] overflow-auto">
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((vItem) => (
<div
key={vItem.key}
style={{
position: 'absolute', top: 0, left: 0, width: '100%',
height: vItem.size,
transform: `translateY(${vItem.start}px)`,
}}
>
<Row row={rows[vItem.index]} />
</div>
))}
</div>
</div>
);
}
Now only ~40 rows exist in the DOM at any time; as you scroll, the virtualizer recycles them.
4.3 Metrics
| Metric | Before | After |
|---|---|---|
| DOM nodes (table) | 96,000+ | ~320 |
| Initial table mount | 1,400ms | 45ms |
| Scroll FPS | 22 fps | 60 fps |
| Memory (heap) | 310 MB | 120 MB |
| Filter re-render time | 210ms | 18ms |
This is the single biggest win in the whole guide. Any list over ~200 rows should be virtualized.
5. Optimization 3 — Code splitting & lazy loading
5.1 The problem
The ExportModal (pulls in a heavy xlsx library) and SettingsModal are bundled into the initial JS — even though most users never open them. They bloat the bundle and delay first paint.
BEFORE — everything in one bundle:
main.js (880 KB) = Dashboard + Table + Charts + ExportModal + xlsx + SettingsModal
└─ shipped even if never opened ─┘
5.2 The fix — React.lazy + Suspense (or next/dynamic)
import { lazy, Suspense, useState } from 'react';
// These chunks load ONLY when the modal is opened
const ExportModal = lazy(() => import('./ExportModal'));
const SettingsModal = lazy(() => import('./SettingsModal'));
function Dashboard() {
const [modal, setModal] = useState<'export' | 'settings' | null>(null);
return (
<>
<button onClick={() => setModal('export')}>Export</button>
{modal === 'export' && (
<Suspense fallback={<ModalSkeleton />}>
<ExportModal onClose={() => setModal(null)} />
</Suspense>
)}
{modal === 'settings' && (
<Suspense fallback={<ModalSkeleton />}>
<SettingsModal onClose={() => setModal(null)} />
</Suspense>
)}
</>
);
}
AFTER — split bundles:
main.js (310 KB) ← loads immediately
export-modal.chunk.js ← loads only when Export clicked
settings-modal.chunk.js ← loads only when Settings clicked
In Next.js, use next/dynamic:
import dynamic from 'next/dynamic';
const ExportModal = dynamic(() => import('./ExportModal'), {
loading: () => <ModalSkeleton />,
ssr: false, // interaction-only — don''t render on the server
});
5.3 Metrics
| Metric | Before | After |
|---|---|---|
| Initial JS bundle (gzipped) | 880 KB | 310 KB |
| Initial JS parse/exec | 2.3s | 0.9s |
| LCP | 3.4s | 2.0s |
| TBT | 1,180ms | 380ms |
6. Optimization 4 — Shrink the bundle (analyze, then cut)
6.1 The fix — analyze, then trim
npx vite-bundle-visualizer # Vite
# or: ANALYZE=true next build # Next.js with @next/bundle-analyzer
Common cuts:
// BEFORE — imports the entire library
import _ from 'lodash';
import * as Icons from 'react-icons/fa';
import moment from 'moment'; // 70 KB, includes all locales
// AFTER — import only what you use (tree-shakeable)
import debounce from 'lodash/debounce'; // ~2 KB
import { FaDownload } from 'react-icons/fa'; // one icon
import { format } from 'date-fns'; // ~10 KB, tree-shakeable
// next.config.js — auto-optimize package imports
module.exports = {
experimental: {
optimizePackageImports: ['lucide-react', 'date-fns', '@mui/material'],
},
};
6.2 Metrics
| Bundle change | Saved |
|---|---|
moment → date-fns | −60 KB |
lodash → lodash/x | −68 KB |
react-icons/* → named | −40 KB |
| Tree-shaking the chart lib | −35 KB |
| Total main bundle | 310 KB → 240 KB |
7. Optimization 5 — Move work to the server (RSC / SSR / streaming)
7.1 The fix — React Server Components (Next.js)
// app/dashboard/page.tsx — a Server Component (no 'use client')
import { DataTable } from './DataTable'; // client component (interactive)
import { ChartPanel } from './ChartPanel';
import { db } from '@/lib/db';
export default async function DashboardPage() {
// Runs on the SERVER — data is ready in the HTML, no client fetch waterfall
const initialRows = await db.transactions.findMany({ take: 200, orderBy: { date: 'desc' } });
return (
<div>
<ChartPanel data={await db.transactions.aggregate(/* ... */)} />
<DataTable initialRows={initialRows} /> {/* hydrates only the interactive part */}
</div>
);
}
The static shell + first data render as HTML on the server; only the interactive bits ship JS and hydrate. Streaming flushes the shell instantly while data resolves:
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<>
<DashboardShell /> {/* instant */}
<Suspense fallback={<TableSkeleton />}>
<DataSection /> {/* streams in when ready */}
</Suspense>
</>
);
}
7.2 Metrics
| Metric | Before | After |
|---|---|---|
| LCP | 2.0s | 1.3s |
| Time to first content | 1.1s | 0.2s (streamed shell) |
| Client JS for initial render | full | ~60% less |
| Data fetch waterfall | client | eliminated (server-side) |
8. Optimization 6 — Keep interactions responsive (concurrent features)
8.1 The fix — useTransition + useDeferredValue
import { useState, useTransition } from 'react';
function FilterableTable({ allRows }: { allRows: Transaction[] }) {
const [query, setQuery] = useState('');
const [filtered, setFiltered] = useState(allRows);
const [isPending, startTransition] = useTransition();
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value); // URGENT — keep the input snappy
startTransition(() => { // NON-URGENT — can be interrupted
setFiltered(allRows.filter((r) => r.merchant.includes(e.target.value)));
});
}
return (
<>
<input value={query} onChange={onChange} />
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<VirtualTable rows={filtered} />
</div>
</>
);
}
useDeferredValue is the lighter alternative:
const deferredQuery = useDeferredValue(query);
const filtered = useMemo(
() => allRows.filter((r) => r.merchant.includes(deferredQuery)),
[allRows, deferredQuery],
);
8.2 Metrics
| Metric | Before | After |
|---|---|---|
| INP (typing in filter) | 180ms | 90ms |
| Input lag (felt) | noticeable | none |
| Dropped frames while filtering | ~8 | 0 |
9. Optimization 7 — Context, debouncing, and the smaller wins
9.1 Split contexts
// BEFORE — one context; changing `theme` re-renders everything reading `user`
const AppContext = createContext({ user, theme, setTheme, notifications });
// AFTER — split by change frequency; consumers only re-render for their slice
const UserContext = createContext(user); // rarely changes
const ThemeContext = createContext({ theme, setTheme }); // changes on toggle
const NotifyContext = createContext(notifications); // changes often
9.2 Debounce expensive handlers
import { useMemo } from 'react';
import debounce from 'lodash/debounce';
function SearchBox({ onSearch }: { onSearch: (q: string) => void }) {
const debounced = useMemo(() => debounce(onSearch, 300), [onSearch]);
return <input onChange={(e) => debounced(e.target.value)} />;
}
9.3 Stable keys
// BEFORE — index keys: React can''t track moves; re-renders/reorders break
{rows.map((row, i) => <Row key={i} row={row} />)}
// AFTER — stable unique keys: React reconciles correctly, reuses DOM
{rows.map((row) => <Row key={row.id} row={row} />)}
9.4 Optimize images (CLS + LCP)
import Image from 'next/image';
// Explicit width/height prevents layout shift; lazy by default; modern formats
<Image src="/chart.png" alt="" width={1200} height={630} priority={false} />
9.5 Metrics for the smaller wins
| Change | Impact |
|---|---|
| Context split | −30% consumer re-renders on theme toggle |
| Debounced search | network calls 1/keystroke → 1/300ms (~90% fewer) |
| Stable keys | eliminated full list re-render on reorder |
next/image sizing | CLS 0.18 → 0.02 |
10. The full before/after metrics matrix
The same production dashboard, profiled before any work and after all techniques, on a 4×-throttled mid-range device:
| Metric | Baseline | After all optimizations | Improvement |
|---|---|---|---|
| LCP (load) | 4.1s | 1.3s | 68% faster |
| INP (interactivity) | 420ms | 90ms | 79% faster |
| TBT (main-thread block) | 1,180ms | 210ms | 82% less |
| CLS (stability) | 0.18 | 0.02 | 89% less shift |
| JS bundle (gzipped) | 880 KB | 240 KB | 73% smaller |
| Filter re-render | 678ms | 18ms | 97% faster |
| Table mount | 1,400ms | 45ms | 97% faster |
| Scroll FPS | 22 fps | 60 fps | smooth |
| Memory (heap) | 310 MB | 120 MB | 61% less |
| JS parse/exec | 2.3s | 0.7s | 70% faster |
Where the wins came from (rough share of the total improvement):
████████████████████ Virtualization (#2) ~35%
████████████ Code splitting + bundle (#3,4) ~25%
█████████ Server Components / SSR (#5) ~18%
██████ Re-render elimination (#1) ~12%
███ Concurrent features (#6) ~6%
██ Smaller wins (#7) ~4%
The lesson: the top three techniques (virtualization, code-splitting, server rendering) delivered ~78% of the gains. Don''t start with sprinkling useMemo everywhere — start with the structural wins.
11. The optimization decision flow
App feels slow? → PROFILE FIRST (React DevTools + Lighthouse)
│
├── Slow initial load (high LCP / big bundle)?
│ → Code-split + lazy-load (#3)
│ → Shrink bundle: analyze + tree-shake (#4)
│ → Server-render the shell (RSC/SSR) (#5)
│
├── Janky scrolling / huge list?
│ → Virtualize (#2) ← almost always the answer
│
├── Laggy typing / clicking (high INP)?
│ → useTransition / useDeferredValue (#6)
│ → Debounce expensive handlers (#7)
│
├── Components re-rendering too often?
│ → memo + stable callbacks (#1), or React Compiler
│ → Split contexts (#7)
│
└── Layout jumping (high CLS)?
→ Set explicit image/element dimensions (#7)
After EACH change: RE-MEASURE. No improvement → revert it.
12. The honest stuff
- Measure or you''re guessing. Half of "optimizations" do nothing or regress because nobody profiled. The Profiler + Lighthouse are non-negotiable.
- The React Compiler (React 19) makes manual
useMemo/useCallbackmostly obsolete. If you''re on React 19, enable it and delete the memo noise. - Structural wins beat micro-optimizations. Virtualization, code-splitting, and server rendering each dwarf a hundred
useMemos. Do them first. useMemo/memohave a cost. Applied everywhere, they can make an app slower. Apply where measured.- INP is the metric that matters most for "feels slow." Users judge responsiveness more than load time.
useTransitionis your friend. - Re-measure on a throttled device. Your M3 MacBook hides problems your users on a low-end Android feel acutely. Always test with CPU throttling.
13. The mental checklist
Before shipping a React perf change:
- You profiled and have a baseline number for the metric you''re improving.
- The change targets the actual bottleneck (not a guess).
- Long lists (>200 rows) are virtualized.
- Heavy/rare components are lazy-loaded with
Suspense. - The bundle was analyzed; wholesale imports trimmed to named imports.
- Initial render is server-rendered where possible (RSC/SSR).
- Expensive state updates use
useTransition/useDeferredValue. - Lists use stable unique keys (not array index).
- Images/elements have explicit dimensions (no CLS).
- You re-measured — the metric actually moved. If not, you reverted.
14. Closing — the right mental model
React performance optimization is not "add useMemo and hope." It''s a measurement-driven loop: profile → find the real bottleneck → apply the right technique → re-measure → keep it only if the number moved. The biggest wins are structural (virtualize, split, server-render), not micro (memoize). And in React 19, the compiler handles most of the micro-optimization for you.
Three habits that make you fast at this:
- Profile first, always. A number you measured beats an optimization you assumed.
- Reach for structural wins first. Virtualization and code-splitting deliver 10× what scattered memoization does.
- Re-measure every change. If the metric didn''t move, the change is complexity for nothing — revert it.
Apply this loop to your own app, and "make it faster" stops being a vague ticket — it becomes a series of measured, defensible wins, each with a before/after number you can show.
Further reading
- React docs — Render and Commit — how rendering actually works.
- React Compiler — the React 19 auto-memoization.
- web.dev — Core Web Vitals — LCP, INP, CLS explained.
- @tanstack/react-virtual — the virtualization library used here.
- Next.js — Optimizing — code-splitting, images, bundles.
- Profiling with React DevTools — the Profiler tab is your most important tool.
Got a React app that''s slow and you''re not sure where the time goes? Email randhir.jassal@gmail.com with a React Profiler flamegraph screenshot and I''ll tell you which of these techniques will move the needle most.
Get the next issue
A short, curated email with the newest posts and questions.