React Performance Debugging: Finding Hidden Bottlenecks
Your React application is slow. Users notice. The problem is that "slow" in React is rarely one big bottleneck — it is dozens of small ones that compound. A component re-renders unnecessarily 50 times per interaction. A context update triggers a cascade across the entire tree. A list renders 500 items when only 20 are visible.
This guide covers how to find and fix the performance issues that profiling tools reveal but do not explain.
Problem
React performance issues show up as:
- Janky scrolling and laggy input fields
- Seconds-long delays when switching tabs or views
- High memory usage that grows over time
- UI freezes during data-heavy operations
React DevTools Profiler
Open React DevTools, go to the Profiler tab, and record an interaction:
- Click "Record"
- Perform the slow action
- Click "Stop"
The flame graph shows exactly how long each component took to render. Focus on:
- Components that render more than once per interaction
- Components that take longer than 16ms (one frame)
- Components deep in the tree that re-render when their props have not changed
Unnecessary Re-Renders
The most common performance issue. A parent re-renders, so every child re-renders — even if their props are identical:
// BAD: Button re-renders every time App state changes
function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState("");
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<ExpensiveList count={count} />
</div>
);
}
Every keystroke in the input causes ExpensiveList to re-render even though count has not changed.
// GOOD: Memoize to prevent unnecessary re-renders
const MemoizedList = React.memo(ExpensiveList);
function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState("");
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<MemoizedList count={count} />
</div>
);
}
React.memo does a shallow comparison of props. If props have not changed, the component skips rendering.
Context Performance Traps
Context is the most common source of hidden re-renders:
// BAD: Every consumer re-renders when ANY value changes
const AppContext = createContext<{
user: User;
theme: Theme;
notifications: Notification[];
}>({ user: null, theme: "dark", notifications: [] });
When notifications update (which happens frequently), every component consuming user or theme also re-renders.
// GOOD: Split contexts by update frequency
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<Theme>("dark");
const NotificationContext = createContext<Notification[]>([]);
Components consuming UserContext no longer re-render when notifications change.
Virtualization
Never render thousands of DOM nodes:
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((row) => (
<div
key={row.key}
style={{
position: "absolute",
top: row.start,
height: row.size,
width: "100%",
}}
>
<ListItem item={items[row.index]} />
</div>
))}
</div>
</div>
);
}
This renders only the visible items plus a small overscan buffer.
UseCallback and UseMemo
Use them where they matter — not everywhere:
// UNNECESSARY: Simple values don't need memoization
const label = useMemo(() => `Count: ${count}`, [count]);
// USEFUL: Expensive computation that runs on every render
const sortedItems = useMemo(
() => items.slice().sort((a, b) => a.priority - b.priority),
[items]
);
// USEFUL: Stable reference for memoized child components
const handleClick = useCallback(
(id: string) => dispatch({ type: "SELECT", id }),
[dispatch]
);
Lazy Loading Routes
Split your bundle by route:
import { lazy, Suspense } from "react";
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
Each route loads only when the user navigates to it. This can reduce initial bundle size by 50-80%. For more on diagnosing React rendering issues, see debugging React layout issues.
Common Mistakes
Mistake 1: Memoizing everything. useMemo and useCallback have overhead. If the computation is trivial, memoization costs more than re-computing.
Mistake 2: Putting too much in one context. Split contexts by update frequency. High-frequency data (notifications, real-time) should never share a context with low-frequency data (user, theme).
Mistake 3: Measuring in development mode. React DevTools runs significantly slower in dev mode. Always profile production builds for accurate timings.
Takeaways
React performance debugging starts with measurement, not guesswork. Use the Profiler to find which components re-render unnecessarily. Split contexts, virtualize long lists, lazy-load routes, and memoize only where the profiler shows it matters.