React Performance Optimization
Overview
Performance optimization in React focuses on two primary goals: reducing initial load time (code splitting, bundle optimization) and maintaining responsive interactions (preventing unnecessary re-renders, optimizing expensive operations).
This guide covers strategies for building performant React applications, with emphasis on measurement-driven optimization rather than premature optimization.
The fundamental insight: React is fast by default. Most performance issues come from:
- Rendering too much: Components re-render when props/state haven't actually changed
- Loading too much: Large JavaScript bundles block initial render
- Computing too much: Expensive calculations run on every render
- Rendering inefficiently: Rendering thousands of DOM nodes when only a few are visible
Core Principles
1. Measure First
Never optimize without data. Profile your application to identify actual bottlenecks, not assumed ones.
Why measurement matters:
- Premature optimization wastes time optimizing non-bottlenecks
- Performance intuition misleads what you think is slow often isn't
- Optimizations have costs: Memoization uses memory, code splitting adds complexity
Tools for measurement:
- React DevTools Profiler: Identifies slow components
- Chrome DevTools Performance: Measures JavaScript execution, rendering, layout
- Lighthouse: Measures initial load performance
- Web Vitals: Measures user-centric metrics (LCP, FID, CLS)
2. Optimize Critical Path
Focus optimization efforts on user-facing interactions: initial page load, route transitions, form submissions, data fetching.
Priority order:
- Initial load: Code splitting, bundle size, lazy loading
- User interactions: Click handlers, form inputs, navigation
- Background operations: Analytics, prefetching, background syncs
3. Lazy Load Non-Critical
Defer loading of code and data that isn't immediately needed. This reduces initial bundle size and speeds up first render.
Lazy load candidates:
- Routes the user hasn't navigated to
- Features behind tabs/accordions
- Modals/dialogs not shown initially
- Heavy libraries (charts, rich text editors)
- Images below the fold
4. Minimize Re-Renders
React's default behavior is to re-render child components whenever parent re-renders, even if props haven't changed. Strategically prevent this with React.memo, useMemo, useCallback.
When re-renders matter:
- Component has expensive calculations
- Component renders large lists
- Component renders frequently (animations, polling)
When re-renders don't matter: Most components are fast enough without optimization. Don't memoize everything.
5. Virtualize Long Lists
When rendering lists with hundreds or thousands of items, only render the visible items. Use react-window or react-virtualized to virtualize large lists.
Virtualization benefits:
- Constant render time: Rendering 10 items vs 10,000 items takes the same time
- Reduced memory: Fewer DOM nodes in memory
- Faster interactions: Scrolling, sorting, filtering remain fast
6. Bundle Optimization
Reduce JavaScript bundle size through code splitting, tree shaking, and removing unused dependencies. Every kilobyte of JavaScript delays page load.
Techniques:
- Code splitting: Split into route-based chunks
- Tree shaking: Remove unused exports from libraries
- Dependency audit: Remove or replace large dependencies
- Dynamic imports: Load features on demand
Measuring Performance
Before optimizing, measure to identify bottlenecks. Use React's built-in profiler and browser tools.
React DevTools Profiler
The React DevTools Profiler is the primary tool for identifying performance issues in React applications.
How to use:
- Install React DevTools browser extension
- Open DevTools → Profiler tab
- Click "Record" button
- Perform the interaction you want to measure (navigate, submit form, filter list)
- Click "Stop"
- Analyze the flame graph
What to look for:
- Yellow/red components: Components that took significant time to render
- Unnecessary re-renders: Components rendering when props/state didn't change
- Deep call stacks: Many nested component renders
- Multiple renders: Components rendering multiple times in one update
Interpreting results:
- Flame graph: Visualizes component render tree, width = render duration
- Ranked chart: Lists components by render time (longest first)
- Component detail: Shows why component rendered (props/state/parent changed)
Common issues identified:
- Expensive components rendering frequently
- Child components re-rendering unnecessarily
- Large lists rendering all items
- Components performing expensive calculations on every render
Programmatic Profiler
Use React's <Profiler> component to measure specific parts of your app programmatically, useful for production monitoring.
import { Profiler, ProfilerOnRenderCallback } from 'react';
const onRenderCallback: ProfilerOnRenderCallback = (
id, // Profiler ID
phase, // 'mount' or 'update'
actualDuration, // Time spent rendering this update (ms)
baseDuration, // Estimated time to render without memoization (ms)
startTime, // When React began rendering this update
commitTime // When React committed this update
) => {
// Send to analytics service
if (actualDuration > 100) {
console.warn(`Slow render detected in ${id}:`, {
phase,
actualDuration,
baseDuration
});
// Send to monitoring service (Datadog, Sentry, etc.)
sendMetric('react.render.duration', actualDuration, { component: id, phase });
}
};
function App() {
return (
<Profiler id="PaymentDashboard" onRender={onRenderCallback}>
<PaymentDashboard />
</Profiler>
);
}
When to use: Production monitoring, identifying slow renders in real user environments. Don't use extensively in development (adds overhead).
Metrics to track:
actualDuration > 100ms: Render took more than 100ms (feels slow to users)actualDuration > baseDuration * 1.5: Memoization not working effectively- Render frequency: How often component renders
Chrome DevTools Performance
Use Chrome's Performance tab for deeper analysis beyond React:
- Open DevTools → Performance tab
- Click "Record"
- Perform interaction
- Stop recording
- Analyze timeline
What to look for:
- Long tasks (> 50ms): JavaScript blocking the main thread
- Layout thrashing: Repeated layout calculations
- Scripting time: Time spent in JavaScript execution
- Rendering time: Time spent painting pixels
Optimizations this reveals:
- Heavy JavaScript computation (move to Web Workers)
- Forced synchronous layout (batch DOM reads/writes)
- Expensive CSS selectors (simplify selectors)
- Too much JavaScript (code splitting needed)
Preventing Re-Renders
React.memo
import { memo } from 'react';
interface PaymentCardProps {
payment: Payment;
onView: (id: string) => void;
}
// GOOD: Memoize expensive components
export const PaymentCard = memo<PaymentCardProps>(({ payment, onView }) => {
console.log('Rendering PaymentCard:', payment.id);
return (
<div className="payment-card">
<h3>{payment.id}</h3>
<p>{payment.amount} {payment.currency}</p>
<button onClick={() => onView(payment.id)}>View</button>
</div>
);
});
// Custom comparison
export const ComplexCard = memo<PaymentCardProps>(
({ payment, onView }) => {
// Complex rendering logic
return <div>...</div>;
},
(prevProps, nextProps) => {
// Return true to skip re-render
return prevProps.payment.id === nextProps.payment.id &&
prevProps.payment.amount === nextProps.payment.amount;
}
);
useMemo
import { useMemo } from 'react';
function PaymentStatistics({ payments }: { payments: Payment[] }) {
// GOOD: Expensive calculation memoized
const statistics = useMemo(() => {
console.log('Calculating statistics');
return {
total: payments.reduce((sum, p) => sum + p.amount, 0),
average: payments.length > 0
? payments.reduce((sum, p) => sum + p.amount, 0) / payments.length
: 0,
byStatus: payments.reduce((acc, p) => {
acc[p.status] = (acc[p.status] || 0) + 1;
return acc;
}, {} as Record<string, number>),
byCurrency: payments.reduce((acc, p) => {
acc[p.currency] = (acc[p.currency] || 0) + p.amount;
return acc;
}, {} as Record<string, number>)
};
}, [payments]);
return (
<div>
<div>Total: ${statistics.total.toFixed(2)}</div>
<div>Average: ${statistics.average.toFixed(2)}</div>
<div>By Status: {JSON.stringify(statistics.byStatus)}</div>
</div>
);
}
useCallback
import { useCallback, useState } from 'react';
function PaymentList() {
const [payments, setPayments] = useState<Payment[]>([]);
// GOOD: Callback memoized to prevent child re-renders
const handlePaymentView = useCallback((id: string) => {
console.log('Viewing payment:', id);
// Navigation logic
}, []);
const handlePaymentDelete = useCallback((id: string) => {
setPayments(prev => prev.filter(p => p.id !== id));
}, []);
return (
<div>
{payments.map(payment => (
<PaymentCard
key={payment.id}
payment={payment}
onView={handlePaymentView}
onDelete={handlePaymentDelete}
/>
))}
</div>
);
}
useMemo and useCallback have overhead. Only use for:
- Expensive calculations (loops, sorting, filtering large datasets)
- Callbacks passed to memoized child components
- Dependency arrays for effects
Don't use for simple calculations or non-memoized children.
Code Splitting
Code splitting divides your JavaScript bundle into smaller chunks that are loaded on demand. This reduces initial load time by deferring code that isn't immediately needed.
How it works: Bundlers (Webpack, Vite, esbuild) detect dynamic import() statements and create separate chunk files. React's lazy() function wraps these dynamic imports, delaying component load until first render.
Benefits:
- Faster initial load: Smaller main bundle downloads and parses faster
- Better caching: Users only re-download changed chunks
- Progressive loading: Load features as needed, not all upfront
Trade-offs:
- More network requests: Each chunk is a separate request
- Complexity: Need fallback UI (loading states) for each lazy boundary
- Waterfalls: Nested lazy components create loading waterfalls
Lazy Loading Routes
Routes are the best candidates for code splitting because users only visit a subset of routes in each session.
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// GOOD: Lazy load route components
// Each route becomes a separate bundle chunk
const PaymentDashboard = lazy(() => import('./pages/PaymentDashboard'));
const AccountDetails = lazy(() => import('./pages/AccountDetails'));
const TransactionHistory = lazy(() => import('./pages/TransactionHistory'));
function App() {
return (
<BrowserRouter>
{/* Single Suspense boundary for all routes */}
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/payments" element={<PaymentDashboard />} />
<Route path="/accounts/:id" element={<AccountDetails />} />
<Route path="/transactions" element={<TransactionHistory />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Best practices:
- Use route-level splitting as baseline (split every route)
- Place
<Suspense>at route level, not individual components (reduces loading flashes) - Provide meaningful loading UI (spinners, skeletons, progress indicators)
Component-Level Code Splitting
Split heavy components that aren't always visible (modals, tabs, accordions, charts).
import { lazy, Suspense, useState } from 'react';
// Heavy component (chart library ~100KB) loaded only when needed
const PaymentChart = lazy(() => import('./components/PaymentChart'));
function PaymentDashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Show Chart</button>
{showChart && (
<Suspense fallback={<div>Loading chart...</div>}>
<PaymentChart />
</Suspense>
)}
</div>
);
}
When to split components:
- Component uses large library (charts, rich text editors, PDF viewers)
- Component is rarely used (admin panels, settings, help dialogs)
- Component is behind user interaction (modals, collapsed sections)
When NOT to split:
- Components are small (< 20KB)
- Components are always visible
- Component is needed immediately (above-the-fold content)
Preloading
Preload chunks before user needs them to eliminate loading delay:
// Preload on hover (user likely to click)
const PaymentDetails = lazy(() => import('./PaymentDetails'));
function PaymentCard({ id }: { id: string }) {
const handleMouseEnter = () => {
// Preload component when user hovers (before click)
import('./PaymentDetails');
};
return (
<div onMouseEnter={handleMouseEnter}>
<Link to={`/payments/${id}`}>View Payment</Link>
</div>
);
}
// Preload on route load
useEffect(() => {
// Prefetch next likely route
import('./pages/TransactionHistory');
}, []);
List Virtualization
React Window
npm install react-window
import { FixedSizeList } from 'react-window';
interface Payment {
id: string;
amount: number;
currency: string;
}
function PaymentList({ payments }: { payments: Payment[] }) {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => {
const payment = payments[index];
return (
<div style={style} className="payment-row">
<span>{payment.id}</span>
<span>{payment.amount} {payment.currency}</span>
</div>
);
};
return (
<FixedSizeList
height={600}
itemCount={payments.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}
Bundle Optimization
Webpack Bundle Analyzer
npm install --save-dev webpack-bundle-analyzer
// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
openAnalyzer: false
})
]
};
Tree Shaking
// BAD: Imports entire library
import _ from 'lodash';
// GOOD: Import only needed functions
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
// GOOD: Named imports (if library supports tree shaking)
import { debounce, throttle } from 'lodash-es';
Image Optimization
Lazy Loading Images
function PaymentReceipt({ receiptUrl }: { receiptUrl: string }) {
return (
<img
src={receiptUrl}
alt="Payment receipt"
loading="lazy" // Native lazy loading
width={600}
height={400}
/>
);
}
Next.js Image Component (if using Next.js)
import Image from 'next/image';
function PaymentReceipt({ receiptUrl }: { receiptUrl: string }) {
return (
<Image
src={receiptUrl}
alt="Payment receipt"
width={600}
height={400}
placeholder="blur"
priority={false} // Lazy load
/>
);
}
Debouncing and Throttling
Search Input Debouncing
import { useState, useCallback } from 'react';
import debounce from 'lodash/debounce';
function PaymentSearch() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useCallback(
debounce((term: string) => {
console.log('Searching for:', term);
// API call
}, 300),
[]
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchTerm(value);
debouncedSearch(value);
};
return <input value={searchTerm} onChange={handleChange} />;
}
Further Reading
React Framework Guidelines
- React General - React fundamentals and patterns
- React State Management - Optimizing state updates with Zustand and React Query
- React Testing - Performance testing strategies
Performance Guidelines
- Performance Overview - Performance strategy across platforms
- Performance Optimization - General optimization techniques
- Performance Testing - Load and performance testing
Web-Specific Guidelines
- Web Components - Component optimization patterns
- Web Styling - CSS performance optimization
External Resources
Summary
Key Takeaways
- Profile first - use React DevTools Profiler to find bottlenecks
- React.memo prevents re-renders for expensive components
- useMemo for expensive calculations, not simple operations
- useCallback for callbacks passed to memoized children
- Code split routes and heavy components with lazy loading
- Virtualize long lists with react-window (1000+ items)
- Optimize bundles with tree shaking and dynamic imports
- Lazy load images with native loading="lazy"
- Debounce search inputs to reduce API calls
- Measure performance with Lighthouse and Web Vitals
Next Steps: Review React Forms for form optimization and Performance Testing for load testing strategies.