Fixing React Performance at Scale: A Senior Engineer's Practical Playbook
Premature optimization is the root of all evil — but so is ignoring performance until your users are complaining. After years of working on React applications that serve millions of users, I've developed a systematic approach to diagnosing and fixing performance problems without guessing. This is that playbook.
We'll cover the full cycle: profiling, identifying root causes, and applying the right fix for the right problem. No cargo-culted useMemo everywhere, no unnecessary complexity. Just targeted solutions.
The Golden Rule Before We Start
Never optimize without measuring first. I've seen engineers spend a week wrapping everything in memo() and useMemo() only to discover the real bottleneck was a single synchronous localStorage read on every render. Profile first. Always.
Step 1: Profile with the React DevTools Profiler
Open Chrome DevTools, install the React Developer Tools extension, and navigate to the Profiler tab.
- Click Record (the circle icon)
- Perform the slow interaction
- Click Stop
What you're looking for:
- Flamechart — Which component took the longest to render?
- Ranked chart — Sort by self time to find the actual culprit vs. components that are slow only because their children are slow
- Why did this render? — Click any bar and look at the reason. This alone saves hours of guessing.
Pay special attention to components that render with (memo) — if they're still re-rendering, their props aren't stable. We'll fix that.
Step 2: Identify Your Problem Category
Performance issues in React almost always fall into one of four categories:
Category A: Unnecessary re-renders
A parent re-renders and takes down an entire subtree that hasn't actually changed.
Category B: Expensive computations on every render
A component does heavy work (sorting, filtering, transforming) that doesn't need to happen unless specific values change.
Category C: Too many components in the render tree
Large lists, tables, or grids rendering thousands of DOM nodes.
Category D: Waterfalls and layout thrash
Sequential data fetching, or reading layout properties (offsetHeight, getBoundingClientRect) that force style recalculations.
Knowing the category determines the fix. Let's walk through each.
Step 3: Fix Category A — Unnecessary Re-renders
The Setup
Here's a classic problem. A Dashboard component holds state, and every state update causes a deeply nested ExpensiveWidget to re-render — even though it doesn't use that state.
function Dashboard() {
const [selectedTab, setSelectedTab] = useState('overview');
return (
<div>
<TabBar selected={selectedTab} onSelect={setSelectedTab} />
<ExpensiveWidget /> {/* Re-renders on every tab change. Why? */}
</div>
);
}
The Diagnosis
Open the Profiler, switch tabs, and click ExpensiveWidget. The "Why did this render?" panel will say: "The parent component rendered."
The Fix: Component Composition First, Memo Second
Before reaching for React.memo, ask whether you can restructure to avoid the problem entirely.
// ✅ Better: Extract state into a component that doesn't parent ExpensiveWidget
function Dashboard() {
return (
<div>
<TabSection /> {/* Owns selectedTab state */}
<ExpensiveWidget /> {/* Never re-renders due to tab changes */}
</div>
);
}
function TabSection() {
const [selectedTab, setSelectedTab] = useState('overview');
return <TabBar selected={selectedTab} onSelect={setSelectedTab} />;
}
This is the state colocation pattern. Moving state down to the smallest component that needs it eliminates re-render cascades without any memoization overhead.
When you can't restructure (e.g., the state is genuinely needed by multiple siblings), then use React.memo:
const ExpensiveWidget = React.memo(function ExpensiveWidget({ data }) {
// Only re-renders when `data` changes by reference
});
Critical caveat: memo only works if props are referentially stable. If a parent passes an inline object or function, it breaks memo on every render.
// ❌ Breaks memo — new object reference on every render
<ExpensiveWidget config={{ theme: 'dark' }} />
// ✅ Stable reference
const config = useMemo(() => ({ theme: 'dark' }), []);
<ExpensiveWidget config={config} />
Same for callbacks:
// ❌ New function reference every render
<ExpensiveWidget onUpdate={() => doSomething(id)} />
// ✅ Stable reference
const handleUpdate = useCallback(() => doSomething(id), [id]);
<ExpensiveWidget onUpdate={handleUpdate} />
Step 4: Fix Category B — Expensive Computations
The Setup
function ProductList({ products, searchQuery }) {
// This runs on EVERY render, even if products and searchQuery haven't changed
const filtered = products
.filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
.sort((a, b) => b.rating - a.rating);
return filtered.map(p => <ProductCard key={p.id} product={p} />);
}
The Fix: useMemo
function ProductList({ products, searchQuery }) {
const filtered = useMemo(() => {
return products
.filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
.sort((a, b) => b.rating - a.rating);
}, [products, searchQuery]); // Only recalculates when these change
return filtered.map(p => <ProductCard key={p.id} product={p} />);
}
When is this worth it? Run the computation in the Profiler and check its self-time. If it's consistently over 1-2ms and the component re-renders frequently, useMemo is justified. For simple operations like filtering a 10-item array, the memoization overhead often costs more than it saves.
Step 5: Fix Category C — Long Lists
Never render 10,000 DOM nodes. The browser can't handle it, and neither can React's reconciler.
The Fix: Virtualization with TanStack Virtual
npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
function VirtualList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60, // Estimated row height in px
overscan: 10, // Render 10 extra rows above/below viewport
});
return (
<div
ref={parentRef}
style={{ height: '600px', overflow: 'auto' }}
>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ItemRow item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}
This renders only the rows visible in the viewport plus a small overscan buffer — typically 20-40 rows instead of thousands.
Step 6: Fix Category D — Data Fetching Waterfalls
This is the most impactful fix on this list, and the most often overlooked.
The Problem
function UserProfile({ userId }) {
const { data: user } = useQuery(['user', userId], fetchUser);
// This query can't start until the first one finishes
const { data: posts } = useQuery(
['posts', user?.id],
() => fetchPosts(user.id),
{ enabled: !!user }
);
}
Two sequential network requests when they could be parallel.
The Fix: Parallel Queries
function UserProfile({ userId }) {
const [userQuery, postsQuery] = useQueries({
queries: [
{ queryKey: ['user', userId], queryFn: () => fetchUser(userId) },
{ queryKey: ['posts', userId], queryFn: () => fetchPosts(userId) },
],
});
// Both requests fire simultaneously
}
If you're using React Server Components or a framework like Next.js, push the data fetching up to the server layer and pass it down as props. Zero waterfalls, zero loading spinners for the user.
Step 7: Measure the Improvement
After every fix, go back to the Profiler and run the same interaction.
What to check:
- Has total render time decreased?
- Has the number of components in the commit decreased?
- Do previously over-rendering components now say "Did not render" in the timeline?
Document before/after numbers. This matters for PR reviews and for building your own intuition about which fixes actually move the needle.
What I Don't Do Anymore
After years of doing this, here's what I've stopped wasting time on:
useMemo on primitive values. useMemo(() => userId + '-profile', [userId]) is pure overhead.
React.memo on every component by default. Some teams add it preemptively to all components. The overhead of comparing props on every render often outweighs the cost of just re-rendering simple components.
Chasing milliseconds on non-interactive paths. A page that takes 400ms to load feels fine. Optimize interactions first — clicks, typing, scrolling — because those are what users feel.
Conclusion
React performance work is mostly detective work. Profile first, categorize the problem, apply the targeted fix, measure the result. The engineers I've seen do this well aren't the ones who memorize all the APIs — they're the ones who stay curious, trust their profiling data, and resist the urge to optimize things that don't matter yet.
The next time a component feels slow, open the Profiler before you touch the code. The answer is usually already there.
Top comments (0)