React Performance Optimization: 15 Techniques That Actually Work (2026)
React is fast by default. But as your application grows — more components, more state, more data — performance degrades. Slow renders, janky scrolling, and poor Core Web Vitals hurt both user experience and SEO.
We've optimized dozens of React applications. These 15 techniques are the ones that consistently deliver measurable improvements. No theoretical fluff — just what works.
1. Measure First: React DevTools Profiler
Before optimizing anything, measure. The React Profiler shows exactly which components are slow and why.
// Wrap your app in Profiler (development only)
import { Profiler } from 'react';
function onRender(id, phase, actualDuration) {
console.log(`${id} ${phase} took ${actualDuration}ms`);
}
<Profiler id="App" onRender={onRender}>
<App />
</Profiler>
Or use the React DevTools browser extension → Profiler tab → Record → Interact with your app → Stop.
What to look for:
- Components rendering too frequently
- Components taking too long to render
- Unnecessary re-renders triggered by parent updates
Only optimize what the profiler flags. Premature optimization wastes time.
2. Memoize Expensive Components
import { memo } from 'react';
// Before: re-renders every time parent renders
function ExpensiveList({ items }) {
return items.map(item => <ComplexItem key={item.id} item={item} />);
}
// After: only re-renders when items change
const ExpensiveList = memo(function ExpensiveList({ items }) {
return items.map(item => <ComplexItem key={item.id} item={item} />);
});
When to use memo: Component renders frequently, renders expensively (>5ms), and receives stable props.
When NOT to use: On small/simple components. The memoization check itself costs ~0.5ms — don't use it on components that render in <1ms.
3. useMemo for Expensive Calculations
import { useMemo } from 'react';
function ProductGrid({ products, filter, sort }) {
// Before: recalculates on every render
const filtered = products
.filter(p => p.category === filter)
.sort((a, b) => sort === 'price' ? a.price - b.price : a.name.localeCompare(b.name));
// After: only recalculates when dependencies change
const filtered = useMemo(
() => products
.filter(p => p.category === filter)
.sort((a, b) => sort === 'price' ? a.price - b.price : a.name.localeCompare(b.name)),
[products, filter, sort]
);
return <Grid items={filtered} />;
}
Rule of thumb: Use useMemo when the calculation processes >1000 items or involves complex operations. Don't use it for simple calculations — the overhead exceeds the savings.
4. useCallback for Stable Function References
import { useCallback, memo } from 'react';
function Parent({ data }) {
// Before: new function every render → breaks child memo
const handleClick = (id) => {
console.log('Clicked', id);
};
// After: stable function reference
const handleClick = useCallback((id) => {
console.log('Clicked', id);
}, []);
return <MemoizedChild onClick={handleClick} data={data} />;
}
Without useCallback, every parent render creates a new function reference, which breaks memo on child components. This is the most common cause of unnecessary re-renders.
5. Virtualize Long Lists
Rendering 10,000 DOM nodes will freeze any browser. Virtualization renders only visible items:
import { useVirtualizer } from '@tanstack/react-virtual';
function HugeList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60, // Row height in pixels
overscan: 5, // Render 5 extra rows above/below
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<Row data={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}
Performance impact: Rendering 10,000 items drops from 3+ seconds to <50ms. This is the single biggest win for data-heavy applications.
6. Code Splitting with React.lazy
Don't load the entire app upfront. Split routes and heavy components:
import { lazy, Suspense } from 'react';
// Instead of: import Dashboard from './Dashboard';
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
const Reports = lazy(() => import('./Reports'));
function App() {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
);
}
Each route loads its own JavaScript chunk on demand. Initial page load drops significantly.
Measured impact: Initial bundle drops from 500KB to 120KB (for a typical app). First load performance improves by 60-70%.
7. Optimize Images
Images are the largest assets on most pages. Three optimizations:
Use next/image (Next.js)
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // Load immediately (above the fold)
placeholder="blur" // Show blurred placeholder while loading
/>;
Next.js Image automatically: serves WebP/AVIF formats, lazy-loads below-the-fold images, prevents layout shift, and serves responsive sizes.
Manual Image Optimization
// Lazy load images below the fold
<img
src="image.jpg"
loading="lazy"
decoding="async"
width={400}
height={300}
alt="Description"
/>
Use Modern Formats
<picture>
<source srcset="image.avif" type="image/avif" />
<source srcset="image.webp" type="image/webp" />
<img src="image.jpg" alt="Fallback" />
</picture>
AVIF is 50% smaller than JPEG. WebP is 30% smaller. Both are widely supported in 2026.
8. Debounce Expensive Operations
Search inputs that trigger API calls or filtering need debouncing:
import { useState, useEffect } from 'react';
import { useDebouncedCallback } from 'use-debounce';
function Search({ products }) {
const [query, setQuery] = useState('');
const debouncedSearch = useDebouncedCallback((value) => {
// This only runs 300ms after the user stops typing
const results = products.filter(p =>
p.name.toLowerCase().includes(value.toLowerCase())
);
setResults(results);
}, 300);
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
debouncedSearch(e.target.value);
}}
/>
);
}
Without debouncing, typing "laptop" triggers 6 filter operations (l, la, lap, lapt, lapto, laptop). With debouncing, it triggers 1.
9. Use CSS Transforms for Animations
Animating top, left, width, or height triggers layout recalculations — the browser must reposition every element. CSS transforms use the GPU and never trigger layout:
/* BAD: Triggers layout (slow) */
.animate-bad {
transition: left 0.3s;
left: 100px;
}
/* GOOD: GPU-accelerated (fast) */
.animate-good {
transition: transform 0.3s;
transform: translateX(100px);
}
Animate only these properties for 60fps: transform, opacity, filter.
10. Avoid Inline Object Props
// BAD: New object every render → breaks memo on child
function Parent() {
return <Child style={{ color: 'red', fontSize: 14 }} />;
}
// GOOD: Stable reference
const childStyle = { color: 'red', fontSize: 14 };
function Parent() {
return <Child style={childStyle} />;
}
Inline objects create a new reference every render, defeating React.memo. Move them outside the component or wrap in useMemo.
11. Window Global State Wisely
Context causes all consumers to re-render when the value changes:
// BAD: Everything re-renders when ANY state changes
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [cart, setCart] = useState([]);
return (
<AppContext.Provider value={{ user, theme, cart, setUser, setTheme, setCart }}>
{children}
</AppContext.Provider>
);
}
// GOOD: Split into separate contexts
const UserContext = createContext();
const ThemeContext = createContext();
const CartContext = createContext();
For complex state management, use Zustand or Jotai instead of Context — they only re-render components that subscribe to the changed state.
Zustand Example
import { create } from 'zustand';
const useStore = create((set) => ({
user: null,
cart: [],
setUser: (user) => set({ user }),
addToCart: (item) => set((state) => ({ cart: [...state.cart, item] })),
}));
// Only re-renders when 'user' changes (not when 'cart' changes)
function UserProfile() {
const user = useStore((state) => state.user);
return <div>{user?.name}</div>;
}
12. Batch State Updates
import { unstable_batchedUpdates } from 'react-dom';
// BAD: 3 separate re-renders
function handleApiResponse(data) {
setUser(data.user); // Re-render 1
setPosts(data.posts); // Re-render 2
setTheme(data.theme); // Re-render 3
}
// GOOD: 1 batched re-render (automatic in React 18+ event handlers)
function handleClick() {
setUser(data.user);
setPosts(data.posts);
setTheme(data.theme);
// React 18+ automatically batches these into one re-render
}
// For async code (React 18+ still batches these automatically)
async function fetchData() {
const data = await api.getData();
setUser(data.user); // These are now batched
setPosts(data.posts); // even in async code
}
React 18+ automatically batches state updates, including in promises and timeouts. But if you're on React 17 or earlier, wrap async updates in unstable_batchedUpdates.
13. Use Skeleton Screens (Perceived Performance)
Users perceive skeleton screens as faster than spinners or blank loading states:
function Article({ data, loading }) {
if (loading) return <ArticleSkeleton />;
return (
<article>
<h1>{data.title}</h1>
<p>{data.content}</p>
</article>
);
}
function ArticleSkeleton() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded mb-2" />
<div className="h-4 bg-gray-200 rounded mb-2" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
);
}
Impact: Reduces perceived load time by 30%. Users are more patient with skeleton screens than spinners.
14. Prefetch Critical Resources
import { useEffect } from 'react';
function usePrefetch(route) {
useEffect(() => {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = route;
document.head.appendChild(link);
return () => document.head.removeChild(link);
}, [route]);
}
// Prefetch the dashboard when user hovers over the link
function NavLink({ to, children }) {
usePrefetch(to);
return <Link to={to}>{children}</Link>;
}
Prefetching loads resources before they're needed, so navigation feels instant.
15. Analyze and Optimize Your Bundle
# Install bundle analyzer
npm install --save-dev @next/bundle-analyzer
# Or for Vite/CRA
npm install --save-dev webpack-bundle-analyzer
// Analyze your build
// npx webpack-bundle-analyzer dist/stats.json
Look for:
- Large dependencies that could be replaced with smaller alternatives (moment.js → date-fns, lodash → native methods)
- Duplicate code across chunks
- Unused exports that are bundled anyway (enable tree-shaking with ES modules)
Common Bundle Bloaters
| Heavy Library | Lightweight Alternative | Size Saved | |--------------|----------------------|------------| | moment.js | date-fns | 67KB → 13KB | | lodash | native JS / radash | 25KB → 0-4KB | | axios | fetch / ky | 13KB → 0-3KB | | chart.js | uPlot | 200KB → 40KB |
Performance Budget Checklist
Set concrete targets and enforce them:
| Metric | Target | Tool | |--------|--------|------| | First Contentful Paint | < 1.8s | Lighthouse | | Largest Contentful Paint | < 2.5s | Lighthouse | | Time to Interactive | < 3.8s | Lighthouse | | Total JavaScript | < 200KB (gzip) | Bundle analyzer | | Main thread blocking | < 50ms | Chrome DevTools | | Cumulative Layout Shift | < 0.1 | Lighthouse |
Conclusion
React performance optimization is about working smarter, not harder. The biggest wins come from:
- Virtualize long lists (biggest single improvement)
- Code split by route (fastest initial load)
- Optimize images (largest payload reduction)
- Memoize strategically (fewer unnecessary renders)
Measure with the Profiler, apply the relevant technique, then measure again. Never optimize blindly — the Profiler tells you exactly where the time goes.
Performance is a feature. Users abandon sites that take longer than 3 seconds to load. Every millisecond you save has a direct impact on bounce rate, engagement, and revenue.