React Performance Optimization: The Ultimate Guide — From Quick Wins to Advanced Techniques
Your React app is slow. Maybe it stutters when you type in a search box. Maybe the page takes 6 seconds to load. Maybe that list of 10,000 items makes the browser beg for mercy.
You've probably heard "just use useMemo" or "wrap it in React.memo" — but those are band-aids. Real performance optimization starts with understanding why things are slow and measuring before you optimize.
This guide covers everything: from the rendering model that makes React tick, to quick wins you can ship today, to advanced techniques that make apps genuinely fast. With real code, real metrics, and real opinions about what actually matters.
Table of Contents
- Why React Apps Get Slow
- Understanding React's Rendering Model
- Measuring Performance — React DevTools Profiler
- Quick Wins
- Component Architecture for Performance
- Bundle Optimization
- Rendering Optimization
- Image Optimization
- State Management Performance
- Network Optimization
- Server Components (RSC)
- Web Vitals — LCP, INP, CLS
- Performance Budget
- Real-World Optimization Case Study
- The Complete Optimization Checklist
Why React Apps Get Slow
Before we fix anything, let's understand the three main categories of slowness.
+------------------------------------------------------------------+
| WHY REACT APPS GET SLOW |
+------------------------------------------------------------------+
| |
| +---------------------+ +-----------------+ +----------------+ |
| | Unnecessary | | Large Bundle | | Blocking the | |
| | Re-renders | | Size | | Main Thread | |
| +---------------------+ +-----------------+ +----------------+ |
| |
| Component renders when User downloads 2MB Heavy computation |
| nothing actually changed of JS before seeing blocks painting |
| anything and interaction |
| |
| Impact: Laggy UI, Impact: Slow initial Impact: Frozen UI, |
| slow interactions load, poor LCP bad INP/FID |
| |
+------------------------------------------------------------------+
Here's the uncomfortable truth: most React performance problems are unnecessary re-renders. The component tree re-renders way more than it needs to because of how React's rendering model works. Let's understand that first.
Understanding React's Rendering Model
The Render Cycle
React rendering has three phases:
1. TRIGGER 2. RENDER 3. COMMIT
+-----------------+ +-----------------+ +-----------------+
| State changes | ----> | React calls | --> | React updates |
| (setState, | | component | | the DOM with |
| context change,| | functions, | | the minimal |
| parent render) | | diffs virtual | | changes needed |
| | | DOM trees | | |
+-----------------+ +-----------------+ +-----------------+
(can be expensive (usually fast)
if many components)
The Re-render Cascade
This is the key thing most people miss: when a component re-renders, ALL its children re-render too, regardless of whether their props changed.
function App() {
const [count, setCount] = useState(0);
return (
<div>
<Header /> {/* Re-renders! Even though it takes no props */}
<Counter count={count} onClick={() => setCount(c => c + 1)} />
<Sidebar /> {/* Re-renders! Even though nothing changed */}
<Footer /> {/* Re-renders! Totally unrelated to count */}
</div>
);
}
Every time count changes, App re-renders, and every child gets called again. React will diff the virtual DOM and skip unnecessary DOM updates, but the JavaScript execution of rendering those components still happens. For complex component trees, this adds up fast.
Batched Updates
React 18+ automatically batches state updates, even in async code:
// React 18: Both updates are batched into ONE re-render
async function handleClick() {
const data = await fetchData();
setLoading(false); // Doesn't trigger render yet
setData(data); // Both batched -> single render
}
// Before React 18, async code caused TWO renders
// Now it's always batched. Nice.
Measuring Performance — React DevTools Profiler
The golden rule of performance: measure first, optimize second. Never optimize based on guesses.
Setting Up the Profiler
- Install React DevTools browser extension
- Open DevTools -> Profiler tab
- Click "Record", interact with your app, click "Stop"
Reading Flame Graphs
The Profiler shows a flame graph like this:
+--[ App: 12ms ]---------------------------------------------+
| +--[ Header: 0.5ms ]--+ +--[ Main: 11ms ]-----------+ |
| | | | +--[ UserList: 10ms ]--+ | |
| +----------------------+ | | +--[UserCard: 2ms]-+| | |
| | | +--[UserCard: 2ms]-+| | |
| | | +--[UserCard: 2ms]-+| | |
| | | +--[UserCard: 2ms]-+| | |
| | | +--[UserCard: 2ms]-+| | |
| | +----------------------+ | |
| +----------------------------+ |
+-------------------------------------------------------------+
Wider bars = more time. Colors:
Gray = did not render
Blue = rendered (fast)
Yellow/Orange = rendered (slow, investigate!)
What to Look For
| Signal | Problem | Fix |
|---|---|---|
| Gray components that keep turning blue | Unnecessary re-renders |
React.memo, state colocation |
| One component consistently yellow/orange | Expensive render | Memoize calculations, virtualize lists |
| Many small renders adding up | Death by a thousand cuts | Component splitting, context optimization |
| Large gap between render and commit | Heavy DOM updates | Virtualization, CSS containment |
Why Did This Render?
Enable "Record why each component rendered" in Profiler settings. You'll see exactly why:
UserCard rendered because:
- Props changed: (onClick) <-- Likely an inline function
Sidebar rendered because:
- Parent component rendered <-- Probably unnecessary
Quick Wins
These are the low-hanging fruit. High impact, low effort.
React.memo — Preventing Unnecessary Re-renders
React.memo skips re-rendering a component if its props haven't changed (shallow comparison).
// WITHOUT memo: Sidebar re-renders every time App renders
function Sidebar({ categories }) {
console.log('Sidebar rendered!'); // Logs on EVERY App render
return (
<nav>
{categories.map(c => <a key={c.id} href={c.url}>{c.name}</a>)}
</nav>
);
}
// WITH memo: Only re-renders when categories actually changes
const Sidebar = React.memo(function Sidebar({ categories }) {
console.log('Sidebar rendered!'); // Only logs when categories changes
return (
<nav>
{categories.map(c => <a key={c.id} href={c.url}>{c.name}</a>)}
</nav>
);
});
When NOT to Use React.memo
React.memo isn't free — it adds a shallow comparison on every render. Don't use it when:
// DON'T memo these:
// 1. Component that almost always receives new props
const Timestamp = React.memo(({ time }) => <span>{time}</span>);
// time changes every second — memo comparison is wasted work
// 2. Component that's super cheap to render
const Dot = React.memo(({ color }) => (
<div style={{ width: 8, height: 8, background: color, borderRadius: '50%' }} />
));
// Rendering a single div is faster than the memo comparison
// 3. Component that receives children (children are new objects every render)
const Card = React.memo(({ children }) => <div className="card">{children}</div>);
// children is a new React element every time — memo never skips
The Reference Equality Gotcha
This is the #1 reason React.memo "doesn't work" for people:
function App() {
const [count, setCount] = useState(0);
// BUG: New object created every render -> memo comparison fails!
const style = { color: 'red', fontSize: 16 };
// BUG: New function created every render -> memo comparison fails!
const handleClick = () => console.log('clicked');
// BUG: New array created every render!
const items = data.filter(d => d.active);
return (
<MemoizedChild
style={style} // New reference every time!
onClick={handleClick} // New reference every time!
items={items} // New reference every time!
/>
);
}
The fix: useMemo and useCallback.
useMemo and useCallback — Done Right
function App() {
const [count, setCount] = useState(0);
const [data, setData] = useState(initialData);
// Stable object reference — only changes if dependencies change
const style = useMemo(() => ({ color: 'red', fontSize: 16 }), []);
// Stable function reference
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
// Memoize filtered/computed data
const activeItems = useMemo(
() => data.filter(d => d.active),
[data] // Only recompute when data changes
);
return (
<MemoizedChild
style={style}
onClick={handleClick}
items={activeItems}
/>
);
}
The Overuse Anti-Pattern
Don't memoize everything. It adds complexity and memory overhead.
// OVERKILL: Don't memoize cheap computations
const fullName = useMemo(() => `${first} ${last}`, [first, last]);
// String concatenation is instant. Just write: const fullName = `${first} ${last}`;
// OVERKILL: Don't useCallback for handlers passed to native elements
const handleChange = useCallback((e) => {
setValue(e.target.value);
}, []);
// <input onChange={handleChange} /> — native elements don't use React.memo
// WORTH IT: Memoize expensive computations
const sortedData = useMemo(
() => [...hugeArray].sort((a, b) => a.score - b.score),
[hugeArray]
);
// WORTH IT: Stable reference passed to memoized child
const handleSubmit = useCallback((formData) => {
submitToAPI(formData);
}, []);
// <MemoizedForm onSubmit={handleSubmit} />
Rule of thumb: Use useMemo/useCallback when the value is passed as a prop to a memoized child, or when the computation is genuinely expensive (sorting, filtering large arrays, complex calculations).
The Key Prop — Why Stable Keys Matter
// BAD: Using index as key
{items.map((item, index) => (
<ListItem key={index} item={item} />
))}
// Problem: If items are reordered, inserted, or deleted,
// React matches by index, not identity.
// This causes incorrect state preservation and extra DOM work.
// GOOD: Use a stable unique identifier
{items.map(item => (
<ListItem key={item.id} item={item} />
))}
// React correctly tracks which item is which across renders.
Here's what goes wrong with index keys:
Before delete: After deleting "Bob" (index 1):
Index 0: Alice (key=0) Index 0: Alice (key=0) -> No change, correct
Index 1: Bob (key=1) Index 1: Charlie (key=1) -> React thinks Bob updated!
Index 2: Charlie (key=2) -> React thinks Charlie was removed!
With stable IDs:
id_1: Alice (key=id_1) id_1: Alice (key=id_1) -> No change
id_2: Bob (key=id_2) id_3: Charlie (key=id_3) -> No change
id_3: Charlie (key=id_3) -> React correctly removes id_2
Avoiding Inline Objects and Functions in JSX
Every inline object or function creates a new reference each render:
// BAD: Creates new object and function every render
function UserProfile({ user }) {
return (
<div>
<Avatar
style={{ width: 48, height: 48 }} // New object each render
onClick={() => navigate(`/users/${user.id}`)} // New function each render
/>
<UserStats data={{ posts: user.posts, likes: user.likes }} />
</div>
);
}
// GOOD: Hoist constants, memoize what changes
const avatarStyle = { width: 48, height: 48 }; // Defined once, outside component
function UserProfile({ user }) {
const handleAvatarClick = useCallback(() => {
navigate(`/users/${user.id}`);
}, [user.id]);
const statsData = useMemo(
() => ({ posts: user.posts, likes: user.likes }),
[user.posts, user.likes]
);
return (
<div>
<Avatar style={avatarStyle} onClick={handleAvatarClick} />
<UserStats data={statsData} />
</div>
);
}
Component Architecture for Performance
How you structure your components has a massive impact on performance. These patterns prevent re-renders at the architectural level.
State Colocation — Keep State Close
The most impactful pattern. If state only affects one part of the tree, put it there — not at the top.
// BAD: Search state lives in App, causing everything to re-render on each keystroke
function App() {
const [searchQuery, setSearchQuery] = useState('');
return (
<div>
<Header /> {/* Re-renders on every keystroke! */}
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<ProductGrid /> {/* Re-renders on every keystroke! */}
<Footer /> {/* Re-renders on every keystroke! */}
</div>
);
}
// GOOD: Search state colocated inside SearchBar
function App() {
return (
<div>
<Header />
<SearchBar /> {/* Only this re-renders when typing */}
<ProductGrid />
<Footer />
</div>
);
}
function SearchBar() {
const [searchQuery, setSearchQuery] = useState('');
// Debounce before lifting results up (if needed)
const debouncedSearch = useDebouncedCallback((query) => {
onSearch(query); // Only triggers parent update after debounce
}, 300);
return (
<input
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
debouncedSearch(e.target.value);
}}
placeholder="Search products..."
/>
);
}
Component Splitting — Extract Frequently Updating Parts
// BAD: Clock updates every second, re-rendering everything in the dashboard
function Dashboard() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
return (
<div>
<h1>Dashboard</h1>
<p>Current time: {time.toLocaleTimeString()}</p>
<ExpensiveChart data={salesData} /> {/* Re-renders every second! */}
<ExpensiveTable data={recentOrders} /> {/* Re-renders every second! */}
</div>
);
}
// GOOD: Extract the clock into its own component
function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Clock /> {/* Only this updates each second */}
<ExpensiveChart data={salesData} /> {/* Stable, doesn't re-render */}
<ExpensiveTable data={recentOrders} /> {/* Stable, doesn't re-render */}
</div>
);
}
function Clock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
return <p>Current time: {time.toLocaleTimeString()}</p>;
}
Children as Props Pattern — Avoiding Re-render Propagation
This pattern is elegant and underused. Components passed as children don't re-render when the parent's state changes, because they were created above the state change.
// BAD: ScrollTracker owns state -> children re-render on scroll
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handler = () => setScrollY(window.scrollY);
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
}, []);
return (
<div>
<ScrollIndicator position={scrollY} />
<ExpensiveContent /> {/* Re-renders on every scroll! */}
</div>
);
}
// GOOD: Accept children — they're created outside, so they don't re-render
function ScrollTracker({ children }) {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handler = () => setScrollY(window.scrollY);
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
}, []);
return (
<div>
<ScrollIndicator position={scrollY} />
{children} {/* Does NOT re-render on scroll! */}
</div>
);
}
// Usage
function Page() {
return (
<ScrollTracker>
<ExpensiveContent /> {/* Created here, doesn't know about scroll state */}
</ScrollTracker>
);
}
Why does this work? Because <ExpensiveContent /> is created in Page, not in ScrollTracker. When ScrollTracker re-renders due to scrollY changing, children is the same React element reference — so React skips re-rendering it.
Bundle Optimization
Your users download your entire JavaScript bundle before they can interact with anything. Smaller bundle = faster load.
Code Splitting with React.lazy + Suspense
// BEFORE: Everything in one bundle
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';
import Analytics from './pages/Analytics';
// AFTER: Each page loads only when needed
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));
const Analytics = React.lazy(() => import('./pages/Analytics'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
}
BEFORE code splitting:
+-----------------------------------------------+
| main.bundle.js (2.1 MB) |
| Dashboard + Settings + Analytics + everything |
+-----------------------------------------------+
User downloads ALL 2.1 MB on first visit.
AFTER code splitting:
+------------------+
| main.bundle.js | (400 KB — shared code)
+------------------+
|
+---> dashboard.chunk.js (300 KB, loaded on /)
+---> settings.chunk.js (200 KB, loaded on /settings)
+---> analytics.chunk.js (600 KB, loaded on /analytics)
User downloads 400 KB + 300 KB = 700 KB on first visit.
Settings and Analytics load on demand.
Dynamic Imports for Heavy Libraries
// DON'T import heavy libraries at the top level
import { parse } from 'csv-parse'; // 150 KB added to main bundle
import { Chart } from 'chart.js'; // 200 KB added to main bundle
// DO import them dynamically when needed
function ExportButton({ data }) {
const handleExport = async () => {
const { parse } = await import('csv-parse');
const csv = parse(data);
downloadFile(csv, 'export.csv');
};
return <button onClick={handleExport}>Export CSV</button>;
}
// Lazy load heavy components
const ChartView = React.lazy(() => import('./ChartView'));
function Dashboard({ showChart }) {
return (
<div>
<Stats />
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<ChartView />
</Suspense>
)}
</div>
);
}
Tree Shaking — What Breaks It
Tree shaking removes unused code from your bundle. But certain patterns prevent it from working.
// BARREL FILES BREAK TREE SHAKING
// utils/index.js (barrel file)
export { formatDate } from './date';
export { formatCurrency } from './currency';
export { parseCSV } from './csv'; // 150 KB library
export { generatePDF } from './pdf'; // 300 KB library
// When you import one thing:
import { formatDate } from './utils';
// Bundler MIGHT pull in everything because it can't prove
// the other exports don't have side effects.
// BETTER: Import directly from the source
import { formatDate } from './utils/date';
// Only date utility is included. CSV and PDF stay out.
// NAMED IMPORTS ENABLE TREE SHAKING
import { debounce } from 'lodash-es'; // Only debounce (~1 KB)
// vs
import _ from 'lodash'; // Entire library (~70 KB)
Analyzing Your Bundle
# With webpack
npx webpack-bundle-analyzer stats.json
# With Next.js
ANALYZE=true next build
# With Vite
npx vite-bundle-visualizer
Bundle analysis shows:
+--[main.js 800KB]--------------------------------------+
| +--[react-dom 130KB]--+ +--[moment.js 230KB!!]--+ |
| | | | Do you really need | |
| | Can't avoid this | | ALL locales? Use | |
| | | | date-fns instead | |
| +----------------------+ +-----------------------+ |
| +--[lodash 70KB]-------+ +--[your-code 200KB]---+ |
| | Import individual | | | |
| | functions instead | | This is fine. | |
| +----------------------+ +----------------------+ |
+-------------------------------------------------------+
Common offenders:
moment.js (230 KB) -> date-fns (30 KB for what you use)
lodash (70 KB) -> lodash-es + named imports (2 KB)
chart.js (200 KB) -> Lazy load it
Rendering Optimization
For when you've fixed re-renders and bundle size, but rendering itself is still slow.
Virtualization for Long Lists
Rendering 10,000 DOM nodes is slow no matter what. Virtualization only renders what's visible.
// WITHOUT virtualization: 10,000 DOM nodes in the page
function UserList({ users }) {
return (
<div>
{users.map(user => ( // 10,000 <div>s in the DOM!
<UserCard key={user.id} user={user} />
))}
</div>
);
}
// WITH virtualization: Only ~20 visible items rendered
import { useVirtualizer } from '@tanstack/react-virtual';
function UserList({ users }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: users.length, // Total items: 10,000
getScrollElement: () => parentRef.current,
estimateSize: () => 60, // Estimated row height in px
overscan: 5, // Render 5 extra items above/below viewport
});
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,
transform: `translateY(${virtualItem.start}px)`,
height: `${virtualItem.size}px`,
width: '100%',
}}
>
<UserCard user={users[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}
Without virtualization: With virtualization:
+--[Viewport]--+ +--[Viewport]--+
| Item 1 | <- visible | Item 42 | <- visible
| Item 2 | <- visible | Item 43 | <- visible
| Item 3 | <- visible | Item 44 | <- visible
+--------------+ | Item 45 | <- visible
| Item 4 | <- rendered +--------------+
| Item 5 | but hidden Only these ~10 items exist
| ... | in the DOM. Everything else
| Item 9,999 | is calculated on the fly.
| Item 10,000 |
+--------------+
10,000 DOM nodes ~10 DOM nodes
Debouncing and Throttling Expensive Renders
import { useDeferredValue, useMemo } from 'react';
// Option 1: useDeferredValue (React 18+)
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
const results = useMemo(
() => expensiveFilter(allItems, deferredQuery),
[deferredQuery]
);
return (
<div style={{ opacity: isStale ? 0.6 : 1 }}>
{results.map(item => <ResultCard key={item.id} item={item} />)}
</div>
);
}
// Option 2: Manual debounce for search input
function SearchInput({ onSearch }) {
const [inputValue, setInputValue] = useState('');
// Debounce the actual search, not the input display
useEffect(() => {
const timer = setTimeout(() => {
onSearch(inputValue);
}, 300);
return () => clearTimeout(timer);
}, [inputValue, onSearch]);
return (
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Type to search..."
/>
);
}
useTransition — Non-Blocking Updates
useTransition lets you mark state updates as "low priority" so they don't block user input.
function TabContainer() {
const [activeTab, setActiveTab] = useState('home');
const [isPending, startTransition] = useTransition();
const handleTabChange = (tab) => {
// The tab highlight updates immediately
setActiveTab(tab);
// But the expensive content render is non-blocking
startTransition(() => {
setActiveTab(tab); // This update can be interrupted
});
};
return (
<div>
<TabBar activeTab={activeTab} onTabChange={handleTabChange} />
<div style={{ opacity: isPending ? 0.7 : 1 }}>
{isPending && <Spinner />}
<TabContent tab={activeTab} /> {/* Expensive render */}
</div>
</div>
);
}
Without useTransition:
User clicks tab -> [=====RENDER BLOCKS=====] -> UI updates
Input frozen for 300ms
With useTransition:
User clicks tab -> Tab highlight updates instantly
-> [==RENDER IN BACKGROUND==] -> Content updates
Input remains responsive
Suspense Boundaries for Streaming
// Strategic Suspense boundaries = progressive loading
function Dashboard() {
return (
<div className="dashboard">
{/* Critical: loads first */}
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
<div className="dashboard-grid">
{/* Important: loads second */}
<Suspense fallback={<StatsSkeleton />}>
<StatsCards />
</Suspense>
{/* Below the fold: loads last */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
</div>
);
}
// Each section can load independently without blocking others
Image Optimization
Images are usually the largest assets on a page. Optimizing them has an outsized impact on LCP.
The Basics
// BAD: Unoptimized image
<img src="/hero-image.png" />
// - Full resolution (3000x2000) loaded on mobile
// - No lazy loading
// - No size hints (causes layout shift = bad CLS)
// - PNG instead of modern format
// GOOD: Optimized with next/image
import Image from 'next/image';
<Image
src="/hero-image.jpg"
alt="Hero banner"
width={1200}
height={600}
priority // LCP image: preload it
sizes="(max-width: 768px) 100vw, 1200px"
// Automatically:
// - Serves WebP/AVIF
// - Responsive srcset
// - Lazy loads by default (unless priority)
// - Prevents layout shift (width/height reserved)
/>
Manual Optimization (Without Next.js)
function OptimizedImage({ src, alt, width, height, priority = false }) {
return (
<picture>
{/* Modern format first */}
<source srcSet={`${src}.avif`} type="image/avif" />
<source srcSet={`${src}.webp`} type="image/webp" />
<img
src={`${src}.jpg`}
alt={alt}
width={width}
height={height}
loading={priority ? 'eager' : 'lazy'}
decoding="async"
fetchPriority={priority ? 'high' : 'auto'}
style={{ maxWidth: '100%', height: 'auto' }}
/>
</picture>
);
}
Image Optimization Checklist
| Technique | Impact | Effort |
|---|---|---|
Lazy loading (loading="lazy") |
High | Low |
| Modern formats (WebP/AVIF) | High | Medium |
Responsive images (srcset) |
High | Medium |
| Width/height attributes (prevent CLS) | High | Low |
priority/fetchpriority for LCP image |
Medium | Low |
| CDN with image transformation | High | Medium |
| Blur placeholder while loading | Medium | Low |
State Management Performance
The Context Re-render Problem
React Context triggers a re-render for every consumer when the context value changes — even if the consumer only uses a part of the value that didn't change.
// BAD: Monolithic context — everything re-renders on any change
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
const value = {
user, setUser,
theme, setTheme,
notifications, setNotifications,
};
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
// Problem: When notifications update, EVERY component that
// uses AppContext re-renders — even those that only read theme!
// GOOD: Split contexts by update frequency
const UserContext = createContext();
const ThemeContext = createContext();
const NotificationContext = createContext();
function AppProvider({ children }) {
return (
<UserContext.Provider value={useUserState()}>
<ThemeContext.Provider value={useThemeState()}>
<NotificationContext.Provider value={useNotificationState()}>
{children}
</NotificationContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
);
}
// Now theme consumers don't re-render when notifications change
Zustand — Selectors for Surgical Re-renders
Zustand lets components subscribe to specific slices of state. Only the slice changing triggers a re-render.
import { create } from 'zustand';
const useStore = create((set) => ({
user: null,
theme: 'light',
notifications: [],
cartItems: [],
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
addNotification: (n) => set((s) => ({
notifications: [...s.notifications, n]
})),
addToCart: (item) => set((s) => ({
cartItems: [...s.cartItems, item]
})),
}));
// Component only re-renders when theme changes
function ThemeSwitcher() {
const theme = useStore((state) => state.theme);
const setTheme = useStore((state) => state.setTheme);
// Notifications changing? Cart updating? This component doesn't care.
return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>{theme}</button>;
}
// Component only re-renders when cart changes
function CartBadge() {
const itemCount = useStore((state) => state.cartItems.length);
return <span className="badge">{itemCount}</span>;
}
Jotai — Atomic State
Jotai takes it further with atoms — each piece of state is independent.
import { atom, useAtom, useAtomValue } from 'jotai';
// Define atoms
const userAtom = atom(null);
const themeAtom = atom('light');
const cartAtom = atom([]);
// Derived atom — computed from other atoms
const cartTotalAtom = atom((get) => {
const cart = get(cartAtom);
return cart.reduce((sum, item) => sum + item.price, 0);
});
// Components subscribe to exactly the atoms they need
function CartTotal() {
const total = useAtomValue(cartTotalAtom);
// Only re-renders when cart changes, nothing else
return <span>${total.toFixed(2)}</span>;
}
Network Optimization
React Query (TanStack Query) — Smart Caching
import { useQuery, useQueryClient } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // Data is fresh for 5 minutes (no refetch)
gcTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
});
if (isLoading) return <ProfileSkeleton />;
if (error) return <ErrorState error={error} />;
return <Profile user={data} />;
}
// Prefetching — load data before user navigates
function UserListItem({ user }) {
const queryClient = useQueryClient();
const handleHover = () => {
// Start fetching profile when user hovers the link
queryClient.prefetchQuery({
queryKey: ['user', user.id],
queryFn: () => fetchUser(user.id),
staleTime: 5 * 60 * 1000,
});
};
return (
<Link to={`/users/${user.id}`} onMouseEnter={handleHover}>
{user.name}
</Link>
);
}
Optimistic Updates
Make the UI feel instant by updating before the server confirms.
function LikeButton({ postId, initialLikes }) {
const queryClient = useQueryClient();
const likeMutation = useMutation({
mutationFn: () => likePost(postId),
// Optimistically update the UI
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ['post', postId] });
const previous = queryClient.getQueryData(['post', postId]);
queryClient.setQueryData(['post', postId], (old) => ({
...old,
likes: old.likes + 1,
isLiked: true,
}));
return { previous };
},
// If the mutation fails, roll back
onError: (err, variables, context) => {
queryClient.setQueryData(['post', postId], context.previous);
toast.error('Failed to like post');
},
// Always refetch to sync with server
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['post', postId] });
},
});
return (
<button onClick={() => likeMutation.mutate()}>
{likeMutation.variables ? '...' : `Like (${initialLikes})`}
</button>
);
}
Without optimistic updates:
Click -> [waiting 200ms...] -> UI updates
User notices the delay.
With optimistic updates:
Click -> UI updates instantly -> [server confirms in background]
(rolls back on error)
Feels instant.
Server Components (RSC)
React Server Components run on the server and send zero JavaScript to the client. They're the biggest paradigm shift in React since hooks.
How They Work
Traditional React (Client Components):
Server sends HTML shell -> Client downloads JS -> Client renders
Bundle: [React + Component code + Libraries] = Heavy
Server Components:
Server renders component to HTML + special format
Client receives rendered output — NO JS for that component
Bundle: Only interactive components ship JS
When to Use Server vs Client Components
// SERVER COMPONENT (default in Next.js App Router)
// Zero JS shipped to client
async function ProductPage({ params }) {
// Can directly access database, file system, etc.
const product = await db.products.findById(params.id);
const reviews = await db.reviews.findByProductId(params.id);
return (
<div>
<ProductDetails product={product} />
<ReviewList reviews={reviews} />
{/* Client component for interactivity */}
<AddToCartButton productId={product.id} />
</div>
);
}
// CLIENT COMPONENT (needs "use client" directive)
'use client';
function AddToCartButton({ productId }) {
const [isAdding, setIsAdding] = useState(false);
const handleClick = async () => {
setIsAdding(true);
await addToCart(productId);
setIsAdding(false);
};
return (
<button onClick={handleClick} disabled={isAdding}>
{isAdding ? 'Adding...' : 'Add to Cart'}
</button>
);
}
Decision Guide
| Use Server Components When | Use Client Components When |
|---|---|
| Fetching data | Using state (useState) |
| Accessing backend resources | Using effects (useEffect) |
| Rendering static/read-only content | Handling user events (onClick) |
| Large dependencies (markdown, syntax highlighting) | Using browser APIs |
| Sensitive logic (API keys, DB queries) | Needing real-time updates |
Web Vitals — LCP, INP, CLS
Google uses Core Web Vitals as a ranking factor. They also directly impact user experience.
The Three Metrics
+----------------------------------------------------------------+
| CORE WEB VITALS |
+----------------------------------------------------------------+
| |
| LCP (Largest Contentful Paint) |
| "How long until the main content is visible?" |
| Good: < 2.5s | Needs Work: 2.5-4s | Poor: > 4s |
| |
| INP (Interaction to Next Paint) |
| "How long until the UI responds after I click?" |
| Good: < 200ms | Needs Work: 200-500ms | Poor: > 500ms |
| (Replaced FID in March 2024) |
| |
| CLS (Cumulative Layout Shift) |
| "How much does stuff jump around while loading?" |
| Good: < 0.1 | Needs Work: 0.1-0.25 | Poor: > 0.25 |
| |
+----------------------------------------------------------------+
Improving LCP
LCP is usually an image or a large text block. Here's how to fix it:
// 1. Preload your LCP image
<head>
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
</head>
// 2. Use priority on LCP image
<Image src="/hero.webp" priority />
// 3. Inline critical CSS (don't block render on external stylesheets)
// 4. Server-render the above-the-fold content (SSR/RSC)
// 5. Avoid client-side data fetching for LCP content
// BAD: Show spinner while fetching
function Hero() {
const { data, isLoading } = useQuery({ queryKey: ['hero'], queryFn: fetchHero });
if (isLoading) return <Spinner />; // LCP delayed until fetch completes
return <h1>{data.title}</h1>;
}
// GOOD: Server-render it or use SSG
async function Hero() {
const data = await fetchHero(); // Runs on server
return <h1>{data.title}</h1>; // Included in initial HTML
}
Improving INP
INP measures the worst interaction responsiveness. Usually caused by heavy JavaScript on the main thread.
// BAD: Blocking the main thread on click
function FilterPanel({ items }) {
const [filters, setFilters] = useState({});
const handleFilterChange = (key, value) => {
setFilters(prev => ({ ...prev, [key]: value }));
// This triggers re-render of 10,000 items synchronously
// Main thread blocked for 300ms -> poor INP
};
const filtered = items.filter(item => matchesFilters(item, filters));
return <ItemList items={filtered} />;
}
// GOOD: Use useTransition to keep interactions responsive
function FilterPanel({ items }) {
const [filters, setFilters] = useState({});
const [isPending, startTransition] = useTransition();
const handleFilterChange = (key, value) => {
setFilters(prev => ({ ...prev, [key]: value }));
startTransition(() => {
// Expensive re-render happens at low priority
// Main thread stays responsive for other interactions
});
};
const filtered = useMemo(
() => items.filter(item => matchesFilters(item, filters)),
[items, filters]
);
return (
<div style={{ opacity: isPending ? 0.7 : 1 }}>
<ItemList items={filtered} />
</div>
);
}
Improving CLS
Layout shift happens when elements change size or position after they're painted.
// BAD: Image without dimensions causes layout shift
<img src="/photo.jpg" />
// Browser doesn't know the size until image loads
// Everything below shifts down -> CLS
// GOOD: Always specify dimensions
<img src="/photo.jpg" width={800} height={600} />
// Browser reserves space immediately -> no shift
// BAD: Dynamic content inserted above visible content
function Feed() {
return (
<div>
{newBannerLoaded && <PromoBanner />} {/* Pushes everything down! */}
<FeedItems />
</div>
);
}
// GOOD: Reserve space for dynamic content
function Feed() {
return (
<div>
<div style={{ minHeight: newBannerLoaded ? 'auto' : 80 }}>
{newBannerLoaded && <PromoBanner />}
</div>
<FeedItems />
</div>
);
}
// BAD: Web fonts cause flash of unstyled text
// GOOD: Use font-display: swap and preload fonts
<link rel="preload" as="font" href="/fonts/inter.woff2" crossOrigin="" />
Performance Budget
Set limits and enforce them so performance doesn't degrade over time.
Setting a Budget
// budget.json (for bundlesize or size-limit)
{
"budgets": [
{
"path": "build/static/js/main.*.js",
"maxSize": "200 KB"
},
{
"path": "build/static/js/vendor.*.js",
"maxSize": "150 KB"
},
{
"path": "build/static/css/*.css",
"maxSize": "50 KB"
}
]
}
Enforcing with CI
// package.json
{
"scripts": {
"size": "size-limit",
"size:check": "size-limit --check"
},
"size-limit": [
{
"path": "dist/index.js",
"limit": "200 KB",
"gzip": true
},
{
"path": "dist/vendor.js",
"limit": "150 KB",
"gzip": true
}
]
}
# .github/workflows/perf.yml
- name: Check bundle size
run: npm run size:check
- name: Lighthouse CI
uses: treosh/lighthouse-ci-action@v10
with:
budgetPath: ./budget.json
urls: |
https://staging.myapp.com/
https://staging.myapp.com/dashboard
Recommended Budgets
| Metric | Target | Why |
|---|---|---|
| Main JS bundle (gzipped) | < 200 KB | 3G connection loads in ~2s |
| Total JS (gzipped) | < 400 KB | Including all chunks |
| LCP | < 2.5s | Google's threshold |
| INP | < 200ms | User-perceived responsiveness |
| CLS | < 0.1 | Visual stability |
| Time to Interactive | < 3.5s | User can start using the app |
Real-World Optimization Case Study
Let's walk through optimizing a real e-commerce product listing page.
Before: The Problem
Page: /products (lists 500 products with filters)
Metrics BEFORE optimization:
Bundle size: 1.8 MB (gzipped: 580 KB)
LCP: 4.2s
INP: 450ms (typing in search box)
CLS: 0.35
Time to Interactive: 5.8s
User complaints:
- "The page takes forever to load"
- "Search is laggy when I type"
- "Images jump around while loading"
Step 1: Bundle Analysis (Impact: -400 KB)
Found:
- moment.js (230 KB) — replaced with date-fns (12 KB used)
- Full lodash import (70 KB) — switched to individual imports (3 KB)
- chart.js loaded on product page (200 KB) — lazy loaded (only on analytics page)
Bundle: 1.8 MB -> 1.3 MB (gzipped: 580 KB -> 380 KB)
Step 2: Code Splitting (Impact: -250 KB initial)
// Lazy load non-critical routes
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));
// Lazy load below-the-fold product reviews
const ReviewSection = lazy(() => import('./components/ReviewSection'));
// Initial load: 380 KB -> 180 KB (gzipped)
Step 3: Fix Re-renders (Impact: INP 450ms -> 120ms)
// Problem: Search input re-renders entire product grid on every keystroke
// Fix 1: Colocate search state
// Fix 2: Debounce the filter operation
// Fix 3: Virtualize the product grid (500 items -> ~20 rendered)
// Fix 4: Wrap ProductCard in React.memo
function ProductPage() {
const [filters, setFilters] = useState({});
const [isPending, startTransition] = useTransition();
const handleFilterChange = useCallback((newFilters) => {
startTransition(() => {
setFilters(newFilters);
});
}, []);
return (
<>
<SearchBar onSearch={handleFilterChange} />
<VirtualizedProductGrid filters={filters} isPending={isPending} />
</>
);
}
const ProductCard = React.memo(function ProductCard({ product }) {
return (/* ... */);
});
Step 4: Image Optimization (Impact: LCP 4.2s -> 1.8s, CLS 0.35 -> 0.05)
// Added width/height to all images (fixes CLS)
// Used WebP with AVIF fallback
// Priority loading for first 4 product images (above the fold)
// Lazy loading for everything below the fold
// Blur placeholder for product thumbnails
<Image
src={product.image}
alt={product.name}
width={300}
height={300}
priority={index < 4}
placeholder="blur"
blurDataURL={product.blurHash}
sizes="(max-width: 768px) 50vw, 25vw"
/>
Step 5: Server-Side Rendering (Impact: LCP 1.8s -> 1.2s)
// Moved initial data fetch to server component
// Products are included in initial HTML — no loading spinner
async function ProductPage({ searchParams }) {
const products = await getProducts(searchParams);
return (
<div>
<SearchBar /> {/* Client component for interactivity */}
<ProductGrid initialProducts={products} />
</div>
);
}
After: The Results
Metrics AFTER optimization:
Bundle size: 1.8 MB -> 950 KB (initial: 180 KB gzipped)
LCP: 4.2s -> 1.2s (-71%)
INP: 450ms -> 120ms (-73%)
CLS: 0.35 -> 0.05 (-86%)
Time to Interactive: 5.8s -> 2.1s (-64%)
+----------+--------+--------+-------------+
| Metric | Before | After | Improvement |
+----------+--------+--------+-------------+
| Bundle | 580 KB | 180 KB | -69% |
| LCP | 4.2s | 1.2s | -71% |
| INP | 450ms | 120ms | -73% |
| CLS | 0.35 | 0.05 | -86% |
| TTI | 5.8s | 2.1s | -64% |
+----------+--------+--------+-------------+
The Complete Optimization Checklist
Ranked by impact — start from the top.
Tier 1: High Impact, Do These First
| Optimization | Impact | Effort | Notes |
|---|---|---|---|
| Code splitting (routes) | Very High | Low |
React.lazy + Suspense
|
| Image optimization | Very High | Low-Medium | Width/height, lazy load, WebP |
| Remove/replace heavy deps | High | Medium |
moment -> date-fns, etc. |
| Virtualize long lists | Very High | Medium | If rendering 100+ items |
| Server-side rendering | High | Medium-High | SSR or RSC for LCP content |
| State colocation | High | Low | Move state down the tree |
Tier 2: Medium Impact, Do These Next
| Optimization | Impact | Effort | Notes |
|---|---|---|---|
React.memo on expensive components |
Medium-High | Low | With stable props |
useMemo for expensive computations |
Medium | Low | Sorting, filtering large arrays |
| Split contexts by update frequency | Medium | Low | Avoid monolithic context |
| Debounce/throttle input-driven renders | Medium | Low | Search, resize, scroll handlers |
useTransition for non-urgent updates |
Medium | Low | Filter changes, tab switches |
| Prefetch data on hover/focus | Medium | Low | React Query prefetchQuery
|
Tier 3: Polish
| Optimization | Impact | Effort | Notes |
|---|---|---|---|
| Tree shaking (direct imports) | Medium | Low | Avoid barrel files |
| Optimistic updates | Medium | Medium | Better perceived performance |
| Web font optimization | Low-Medium | Low |
font-display: swap, preload |
| Performance budget in CI | Low (prevents regression) | Medium |
size-limit, Lighthouse CI |
| CSS containment | Low-Medium | Low |
contain: content for complex layouts |
| Dynamic imports for heavy features | Medium | Low | PDF export, charts, editors |
The Anti-Optimization List (Don't Do These)
| Anti-Pattern | Why |
|---|---|
useMemo on every computation |
Overhead of memoization > savings on cheap ops |
React.memo on everything |
Comparison cost on frequently-changing props is wasteful |
| Premature code splitting | 50 tiny chunks cause more network overhead than one medium bundle |
| Over-abstracting for "performance" | Added complexity makes code harder to maintain and debug |
| Optimizing without measuring | You might optimize the wrong thing entirely |
Remember: measure first, then optimize the bottleneck, then measure again. Performance optimization is a science, not a guessing game.
Let's Connect!
If you found this guide helpful, I'd love to connect with you! I regularly share deep dives on system design, backend engineering, and software architecture.
Connect with me on LinkedIn — let's grow together.
Drop a comment, share this with someone who needs this, and follow along for more guides like this!
Top comments (0)