The Performance Mindset
Most React apps are fast enough out of the box. React's virtual DOM diffing is efficient, and modern browsers are powerful. The danger is premature optimization โ adding complexity before measuring.
The performance workflow:
Measure --> Identify Bottleneck --> Optimize --> Measure Again
^ |
+--------------------------------------------------------+
Never skip the first step. Profiling before optimizing tells you what matters. Profiling after optimizing tells you if it worked.
React.memo โ Component Memoization
React.memo is a higher-order component that skips re-rendering when props have not changed. By default, it uses shallow comparison on all props.
import { memo } from 'react';
const ExpensiveList = memo(function ExpensiveList({ items, onSelect }) {
console.log('ExpensiveList rendered');
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
});
When React.memo Helps
Parent re-renders (state change)
|
+---> Child A (props changed) --> RE-RENDERS (correct)
|
+---> Child B (props unchanged) --> SKIPPED by memo (saved work)
|
+---> Child C (no memo, props unchanged) --> RE-RENDERS (wasted)
When React.memo Hurts
If a component receives new object or function references every render, memo's shallow comparison fails every time โ you pay the comparison cost plus the re-render.
// BAD: memo is useless here because `style` is a new object every render
function Parent() {
return <MemoizedChild style={{ color: 'red' }} />;
}
// FIX: hoist the constant or use useMemo
const style = { color: 'red' };
function Parent() {
return <MemoizedChild style={style} />;
}
Custom Comparison Function
const UserCard = memo(function UserCard({ user, theme }) {
return (
<div className={theme}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
}, (prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
return prevProps.user.id === nextProps.user.id
&& prevProps.theme === nextProps.theme;
});
Use custom comparisons sparingly. They are easy to get wrong โ forgetting a prop means stale renders.
useMemo and useCallback
useMemo memoizes a computed value. useCallback memoizes a function reference. Both take a dependency array.
import { useMemo, useCallback } from 'react';
function ProductList({ products, filter, onSelect }) {
// Memoize expensive filtering
const filteredProducts = useMemo(() => {
return products.filter(p => p.category === filter);
}, [products, filter]);
// Memoize callback so memoized children do not re-render
const handleSelect = useCallback((id) => {
onSelect(id);
}, [onSelect]);
return (
<ul>
{filteredProducts.map(product => (
<ProductItem
key={product.id}
product={product}
onSelect={handleSelect}
/>
))}
</ul>
);
}
const ProductItem = memo(function ProductItem({ product, onSelect }) {
return (
<li onClick={() => onSelect(product.id)}>
{product.name} โ ${product.price}
</li>
);
});
Decision Table: When to Use
| Scenario | useMemo? | useCallback? | React.memo? |
|---|---|---|---|
| Expensive computation (sort, filter 1000+ items) | Yes | โ | โ |
| Object/array prop passed to memoized child | Yes | โ | Yes (on child) |
| Function prop passed to memoized child | โ | Yes | Yes (on child) |
| Simple component, few props, cheap render | No | No | No |
| Context value that changes rarely | Yes | โ | โ |
Code Splitting with Dynamic Imports
By default, bundlers create a single JavaScript file containing your entire app. Code splitting breaks it into smaller chunks loaded on demand.
React.lazy and Suspense
import { lazy, Suspense } from 'react';
// This import is replaced with a dynamic import
// The Dashboard chunk is loaded only when needed
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
const Analytics = lazy(() => import('./Analytics'));
function App() {
const [page, setPage] = useState('dashboard');
return (
<nav>
<button onClick={() => setPage('dashboard')}>Dashboard</button>
<button onClick={() => setPage('settings')}>Settings</button>
<button onClick={() => setPage('analytics')}>Analytics</button>
<Suspense fallback={<div className="spinner">Loading...</div>}>
{page === 'dashboard' && <Dashboard />}
{page === 'settings' && <Settings />}
{page === 'analytics' && <Analytics />}
</Suspense>
</nav>
);
}
Route-based Splitting in Next.js
Next.js splits by page automatically. Each file in /pages becomes its own chunk. For further splitting within a page:
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('../components/HeavyChart'), {
loading: () => <p>Loading chart...</p>,
ssr: false, // skip server-side rendering for client-only components
});
export default function AnalyticsPage() {
return (
<div>
<h1>Analytics</h1>
<HeavyChart />
</div>
);
}
Preloading Critical Chunks
const Dashboard = lazy(() => import('./Dashboard'));
// Preload when user hovers over the nav link
function NavLink() {
const handleMouseEnter = () => {
import('./Dashboard'); // triggers chunk download early
};
return (
<button onMouseEnter={handleMouseEnter} onClick={() => navigate('/dashboard')}>
Dashboard
</button>
);
}
This bridges the gap between lazy loading and instant navigation. The chunk starts downloading on hover, so it is often ready by the time the user clicks.
Image Optimization
Images are typically the heaviest assets on a page. React apps (especially Next.js) have powerful tools for optimization.
next/image
import Image from 'next/image';
function ProductCard({ product }) {
return (
<div className="card">
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={300}
placeholder="blur"
blurDataURL={product.blurHash}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority={false}
/>
<h3>{product.name}</h3>
</div>
);
}
What next/image does automatically:
- Responsive sizing โ generates multiple sizes, serves the smallest that fits
- Lazy loading โ images below the fold load on scroll
- WebP/AVIF conversion โ serves modern formats when the browser supports them
- Blur placeholder โ shows a low-res preview while loading
- Priority flag โ set
priorityon above-the-fold images (like hero banners) to preload them
Lazy Loading Without Next.js
import { useState, useRef, useEffect } from 'react';
function LazyImage({ src, alt, width, height }) {
const [isLoaded, setIsLoaded] = useState(false);
const [isInView, setIsInView] = useState(false);
const imgRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
if (imgRef.current) observer.observe(imgRef.current);
return () => observer.disconnect();
}, []);
return (
<div ref={imgRef} style={{ width, height, background: '#e0e0e0' }}>
{isInView && (
<img
src={src}
alt={alt}
width={width}
height={height}
onLoad={() => setIsLoaded(true)}
style={{ opacity: isLoaded ? 1 : 0, transition: 'opacity 0.3s' }}
/>
)}
</div>
);
}
Bundle Size Reduction
Large bundles mean slow initial loads. Here is how to audit and shrink them.
Analyze Your Bundle
# Next.js
ANALYZE=true npm run build
# Or use webpack-bundle-analyzer
npm install --save-dev webpack-bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// your Next.js config
});
Common Bundle Bloaters
| Library | Size (gzipped) | Lighter Alternative |
|---|---|---|
| moment.js | ~72 KB | date-fns (~7 KB tree-shaken), dayjs (~2 KB) |
| lodash (full) | ~72 KB | lodash-es (tree-shakeable), individual imports |
| chart.js | ~60 KB | lightweight-charts, uPlot |
| react-icons (full) | ~40 KB | Import individual icons |
Tree Shaking: Import Only What You Need
// BAD: imports entire library
import { format } from 'date-fns';
// This might still pull in everything depending on your bundler
// GOOD: direct import path
import format from 'date-fns/format';
// BAD: imports all icons
import { FaHome, FaUser } from 'react-icons/fa';
// GOOD: import from specific icon set
import { FaHome } from 'react-icons/fa';
import { FaUser } from 'react-icons/fa';
Measure Before and After
# Check individual dependency sizes
npx bundlephobia <package-name>
# Or check package.json sizes
npx cost-of-modules
Lazy Loading Routes
For single-page apps, route-based code splitting is the highest-impact optimization. Each route becomes its own chunk.
React Router with Lazy Loading
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { lazy, Suspense } from 'react';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Blog = lazy(() => import('./pages/Blog'));
const BlogPost = lazy(() => import('./pages/BlogPost'));
const Contact = lazy(() => import('./pages/Contact'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<GlobalSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/blog" element={<Blog />} />
<Route path="/blog/:slug" element={<BlogPost />} />
<Route path="/contact" element={<Contact />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Without Code Splitting:
+-----------------------------------------------------+
| bundle.js (500 KB) |
| Home + About + Blog + BlogPost + Contact + ... |
+-----------------------------------------------------+
User loads / --> downloads ALL 500 KB
With Code Splitting:
+-------------+ +-------------+ +-------------+
| home.js | | about.js | | blog.js |
| (50 KB) | | (30 KB) | | (80 KB) |
+-------------+ +-------------+ +-------------+
User loads / --> downloads only home.js (50 KB)
Navigates to /about --> downloads about.js (30 KB)
Virtualized Lists
Rendering thousands of DOM nodes is slow regardless of how optimized your React code is. Virtualization renders only the visible items plus a small buffer.
import { useRef, useState, useEffect } from 'react';
function VirtualList({ items, itemHeight, containerHeight }) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef(null);
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(
startIndex + Math.ceil(containerHeight / itemHeight) + 1,
items.length
);
const visibleItems = items.slice(startIndex, endIndex);
const totalHeight = items.length * itemHeight;
const offsetY = startIndex * itemHeight;
return (
<div
ref={containerRef}
onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
style={{ height: containerHeight, overflow: 'auto' }}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ transform: `translateY(${offsetY}px)` }}>
{visibleItems.map((item, i) => (
<div key={startIndex + i} style={{ height: itemHeight }}>
{item.name}
</div>
))}
</div>
</div>
</div>
);
}
For production, use react-window or @tanstack/react-virtual:
import { FixedSizeList } from 'react-window';
function UserList({ users }) {
const Row = ({ index, style }) => (
<div style={style} className="user-row">
{users[index].name} โ {users[index].email}
</div>
);
return (
<FixedSizeList
height={600}
width="100%"
itemCount={users.length}
itemSize={50}
>
{Row}
</FixedSizeList>
);
}
Without Virtualization (10,000 items):
+-----------------------------------+
| DOM: 10,000 <div> elements | Slow render, high memory
| Viewport shows: ~15 items |
+-----------------------------------+
With Virtualization (10,000 items):
+-----------------------------------+
| DOM: ~20 <div> elements | Fast render, low memory
| Viewport shows: ~15 items |
| Buffer: 2-3 above + 2-3 below |
+-----------------------------------+
Avoiding Unnecessary Re-renders
State Colocation
Move state as close to where it is used as possible. State at the top of the tree causes the entire tree to re-render.
// BAD: searchQuery state lives in App, entire tree re-renders on every keystroke
function App() {
const [searchQuery, setSearchQuery] = useState('');
return (
<div>
<Header />
<SearchBar query={searchQuery} onChange={setSearchQuery} />
<ProductList /> {/* re-renders on every keystroke */}
<Footer /> {/* re-renders on every keystroke */}
</div>
);
}
// GOOD: searchQuery state lives in SearchBar, siblings unaffected
function App() {
return (
<div>
<Header />
<SearchBar /> {/* owns its own state */}
<ProductList /> {/* not affected */}
<Footer /> {/* not affected */}
</div>
);
}
Children as Props Pattern
// BAD: ExpensiveComponent re-renders when count changes
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<ExpensiveComponent />
</div>
);
}
// GOOD: ExpensiveComponent is passed as children, does not re-render
function Counter({ children }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
{children}
</div>
);
}
function Parent() {
return (
<Counter>
<ExpensiveComponent />
</Counter>
);
}
This works because children is created during Parent's render. When Counter re-renders due to its own state change, children is the same reference (Parent did not re-render), so React skips re-rendering ExpensiveComponent.
Chrome DevTools Profiler
The React DevTools Profiler records component render timings and helps identify what re-renders and why.
Setup
- Install the React Developer Tools browser extension
- Open DevTools, go to the Profiler tab
- Click the record button, interact with your app, then stop recording
Reading the Flame Graph
Flame Graph Reading Guide:
+--[ App ]----------------------------------+ 15ms total
| +--[ Header ]--------+ 2ms |
| | +--[ Logo ]+ 0.5ms| |
| | +--[ Nav ]+ 1ms | |
| +---------------------+ |
| +--[ ProductList ]-------------+ 12ms | <-- bottleneck
| | +--[ ProductItem ]+ 0.2ms | |
| | +--[ ProductItem ]+ 0.2ms | |
| | +--[ ProductItem ]+ 0.2ms | |
| | ... (200 items) | |
| +------------------------------+ |
+--------------------------------------------+
Key Things to Look For
- Gray bars = components that did not render (good)
- Yellow/orange bars = slow renders (investigate)
- "Why did this render?" โ enable in Profiler settings, shows what triggered each render
- Ranked view โ sorts components by render time, fastest way to find bottlenecks
Common Findings and Fixes
| Profiler Finding | Likely Cause | Fix |
|---|---|---|
| Parent re-renders, all children re-render | State too high in tree | Colocate state, use memo |
| Same component renders many times | List without memoization | React.memo + stable keys |
| Component renders with same props | Missing React.memo | Add React.memo |
| Render is slow (>16ms) | Heavy computation in render | useMemo, move logic to worker |
Lighthouse Audit
Lighthouse audits your page for performance, accessibility, best practices, and SEO. Run it from Chrome DevTools (Lighthouse tab) or the CLI.
npx lighthouse https://your-app.com --view
Key Metrics
| Metric | Target | What It Measures |
|---|---|---|
| First Contentful Paint (FCP) | < 1.8s | Time until first content is visible |
| Largest Contentful Paint (LCP) | < 2.5s | Time until largest visible element loads |
| Total Blocking Time (TBT) | < 200ms | Time main thread was blocked |
| Cumulative Layout Shift (CLS) | < 0.1 | Visual stability (layout jumps) |
| Time to Interactive (TTI) | < 3.8s | Time until page is fully interactive |
Quick Wins from Lighthouse
// 1. Add width and height to images (prevents CLS)
<img src="/photo.jpg" alt="..." width={800} height={600} />
// 2. Preload critical fonts
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossOrigin="" />
// 3. Defer non-critical JavaScript
<script src="/analytics.js" defer />
// 4. Use loading="lazy" on below-fold images
<img src="/photo.jpg" alt="..." loading="lazy" width={800} height={600} />
Identifying and Fixing Bottlenecks
A systematic approach to performance debugging:
Step 1: Establish Baseline
# Run Lighthouse in CI for every build
npx lighthouse https://staging.your-app.com \
--output=json \
--output-path=./lighthouse-report.json
Step 2: Profile in Development
Use React DevTools Profiler to record interactions. Focus on:
- Page loads
- Form submissions
- List scrolling
- Tab switching
Step 3: Check the Renders
// Temporary: log every render with its cause
function DebugRenders({ name }) {
const renderCount = useRef(0);
renderCount.current++;
console.log(`${name} rendered ${renderCount.current} times`);
return null;
}
// Place inside any component you suspect
function ProductList({ products }) {
return (
<div>
<DebugRenders name="ProductList" />
{products.map(p => <ProductItem key={p.id} product={p} />)}
</div>
);
}
Step 4: Measure Specific Operations
function ExpensiveOperation({ data }) {
const result = useMemo(() => {
console.time('expensive-filter');
const filtered = data.filter(/* complex logic */);
console.timeEnd('expensive-filter');
return filtered;
}, [data]);
return <List items={result} />;
}
Step 5: Apply Targeted Fixes
Match the bottleneck to the solution:
Bottleneck: Too many re-renders
Fix: React.memo, useCallback, state colocation
Bottleneck: Expensive computation on every render
Fix: useMemo
Bottleneck: Large bundle size
Fix: Code splitting, tree shaking, lighter libraries
Bottleneck: Rendering 1000+ DOM nodes
Fix: Virtualization (react-window)
Bottleneck: Slow images
Fix: next/image, lazy loading, modern formats
Bottleneck: Layout shifts
Fix: Explicit width/height, font-display: swap
Web Workers for Heavy Computation
When computation takes more than 16ms, it blocks the main thread and causes jank. Move it to a Web Worker.
// worker.js
self.addEventListener('message', (e) => {
const { data, sortKey } = e.data;
const sorted = [...data].sort((a, b) => {
return a[sortKey] > b[sortKey] ? 1 : -1;
});
self.postMessage(sorted);
});
import { useState, useEffect, useRef } from 'react';
function useWorker(workerPath) {
const workerRef = useRef(null);
const [result, setResult] = useState(null);
useEffect(() => {
workerRef.current = new Worker(workerPath);
workerRef.current.onmessage = (e) => setResult(e.data);
return () => workerRef.current.terminate();
}, [workerPath]);
const postMessage = (data) => {
workerRef.current?.postMessage(data);
};
return { result, postMessage };
}
function SortableTable({ data }) {
const { result: sortedData, postMessage } = useWorker('/worker.js');
const handleSort = (key) => {
postMessage({ data, sortKey: key });
};
return (
<table>
<thead>
<tr>
<th onClick={() => handleSort('name')}>Name</th>
<th onClick={() => handleSort('price')}>Price</th>
</tr>
</thead>
<tbody>
{(sortedData || data).map(item => (
<tr key={item.id}>
<td>{item.name}</td>
<td>{item.price}</td>
</tr>
))}
</tbody>
</table>
);
}
Performance Optimization Checklist
Use this as a reference when auditing React app performance:
Rendering
- State colocated as close to usage as possible
- React.memo on components that receive stable props
- useMemo for expensive computations
- useCallback for function props passed to memoized children
- Virtualized lists for 100+ items
Loading
- Route-based code splitting
- Dynamic imports for heavy components
- Image optimization (responsive sizes, lazy loading, modern formats)
- Font preloading with font-display: swap
- Third-party script deferral
Bundle
- Bundle analyzer run, large dependencies identified
- Heavy libraries replaced with lighter alternatives
- Tree shaking verified (no unused exports in bundle)
- No duplicate dependencies in bundle
Measuring
- Lighthouse CI integrated in build pipeline
- React DevTools Profiler used to identify re-render bottlenecks
- Core Web Vitals monitored in production
Key Takeaways
- Measure before optimizing โ never guess at bottlenecks
- React.memo is only useful when props are actually stable (primitives, or memoized objects/functions)
- Code splitting at the route level is the highest-impact optimization for most apps
- Virtualize long lists โ do not render thousands of DOM nodes
- Use
next/imageor manual lazy loading for images - Run Lighthouse audits regularly, track metrics over time
- Move computations over 16ms to Web Workers
- Profile in production mode โ development mode is significantly slower and gives misleading timings