React Profiler in Practice: Finding and Fixing Performance Bottlenecks
Start With Measurement, Not Intuition
The difference between senior engineers and junior engineers when it comes to React performance optimization isn't how many techniques they know. It's whether they measure before acting. Optimizing without data is guessing in the dark — you might accidentally improve something, but you have no idea whether the problem you fixed was the one causing the user pain.
React provides two complementary profiling tools: React DevTools Profiler (visual, interactive, development-time) and the Profiler API (programmatic, integrates with production monitoring and CI). This chapter builds a complete workflow from "something feels slow" to "problem found, fixed, and verified" using both tools, grounded in real antipatterns observed in production codebases.
React DevTools Profiler: A Complete Walkthrough
Installation and Setup
React DevTools is a browser extension for Chrome and Firefox. After installation, open DevTools (F12) and you'll find two new panels: Components and Profiler.
Critical note: The Profiler panel shows meaningful data only in development mode. Production React strips all Profiler-related code to reduce bundle size. For profiling in a production-like environment, use the react-dom/profiling build:
// vite.config.ts: create an alias for profiling builds
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
alias: process.env.PROFILE === 'true'
? {
'react-dom': 'react-dom/profiling',
'scheduler/tracing': 'scheduler/tracing-profiling',
}
: {},
},
});
// Run with: PROFILE=true npm run build && npm run preview
Recording a Session
- Switch to the Profiler tab
- Click the circular record button (it turns red)
- Perform the interaction you want to analyze — click buttons, type in inputs, navigate routes
- Click stop
- React DevTools renders the profiling results
Keep recording sessions short and focused: 5-15 seconds capturing the specific interaction you're investigating. Long recordings produce massive datasets that are hard to navigate.
After stopping, you'll see a list of "commits" — each commit is one batch of updates React applied to the DOM. Click any commit to see its flame graph.
Reading the Flame Graph
The flame graph is the most information-dense view in the Profiler. Each bar represents one component in the tree. Width represents render duration; color represents render frequency relative to other components in this session.
Flame Graph (one commit):
KanbanBoard [████████████████████████ 48ms]
├─ KanbanHeader [█ 1ms]
├─ KanbanColumn (×4) [██ 5ms each]
│ ├─ ColumnHeader [0.2ms]
│ └─ TaskCard (×20) [██ 2ms each] ← 80 cards × 2ms = hot spot
└─ DragOverlay [█ 1ms]
Wide bars = time spent — find the wide bars first. A component might be wide because it does expensive work itself, or because it has many children that each take a little time.
Gray bars = bailed out — gray components were not re-rendered in this commit. If you see gray, React's optimization is working for that subtree.
The most valuable piece of information: why did this component render? Click any component and the right panel shows:
- Why did this render? — which state, prop, or hook changed
- Duration: how long this component and its subtree took
- Render count: how many times this component rendered in the entire recording
The "why did this render" section is where most debugging insight comes from. If a component shows "props changed" and you don't expect props to change, you've found your problem.
Reading the Ranked Chart
The Ranked chart sorts all components by render time in descending order. Use this view when you know something is slow but don't know where to look:
Ranked Chart:
ProductList ████████████████████ 38ms
ChartSection █████████████ 24ms
DataTable ██████████ 19ms
SearchInput ████ 4ms
UserAvatar ██ 2ms
Start with the longest bar. If the top offender surprises you, that's where to dig first.
A Walkthrough Example
Recording a search input interaction reveals:
Commit #4 (triggered by: SearchInput state change)
Components and why they rendered:
SearchInput → state: query ✓ (expected)
ProductList → props: items ✓ (expected — filtered list)
ProductItem ×50 → parent re-render ✗ (unexpected — items didn't change)
Total commit time: 52ms
ProductItem contributions: 50 × ~1ms = ~50ms
Finding: 50 ProductItem components re-rendered because their parent ProductList re-rendered. But the actual displayed items didn't change — the search query was the same as the previous keystroke. The onItemClick handler passed to each ProductItem was a new function reference every render.
Fix: React.memo on ProductItem + useCallback on the handler. After fix, Commit #4 takes 3ms instead of 52ms.
The Profiler API: Programmatic Measurement
Basic Usage
The <Profiler> component calls its onRender callback after every render of its subtree:
import { Profiler, ProfilerOnRenderCallback } from 'react';
const onRender: ProfilerOnRenderCallback = (
id, // The "id" prop you gave to the Profiler
phase, // 'mount' | 'update' | 'nested-update'
actualDuration, // Time spent rendering this commit (ms)
baseDuration, // Estimated time without memoization
startTime, // When React started rendering this update
commitTime // When React committed this update
) => {
// Development: log to console for quick inspection
if (process.env.NODE_ENV === 'development') {
if (actualDuration > 16) {
console.warn(
`Slow render detected: ${id} [${phase}] took ${actualDuration.toFixed(1)}ms`
);
}
}
// Production: report to observability platform
if (process.env.NODE_ENV === 'production' && actualDuration > 100) {
performanceMonitor.track({
event: 'slow_react_render',
component: id,
phase,
duration: Math.round(actualDuration),
page: window.location.pathname,
});
}
};
function App() {
return (
<Profiler id="App" onRender={onRender}>
<Router>
<Routes />
</Router>
</Profiler>
);
}
Nested Profilers for Granular Data
function Dashboard() {
return (
<Profiler id="Dashboard" onRender={onRender}>
<div className="dashboard-layout">
<Profiler id="Dashboard.Sidebar" onRender={onRender}>
<Sidebar />
</Profiler>
<main>
<Profiler id="Dashboard.Charts" onRender={onRender}>
<ChartSection />
</Profiler>
<Profiler id="Dashboard.Table" onRender={onRender}>
<DataTable />
</Profiler>
</main>
</div>
</Profiler>
);
}
// Sample output:
// Dashboard.Sidebar [mount]: 12.3ms
// Dashboard.Charts [mount]: 45.7ms ← investigate this
// Dashboard.Table [mount]: 8.1ms
// Dashboard [mount]: 67.4ms
Nested Profilers let you attribute total render time to specific subsections of the UI. The outer Profiler captures total time including all children; inner Profilers isolate subsections.
Performance Regression Testing in CI
import { render, act } from '@testing-library/react';
import { Profiler } from 'react';
describe('Performance budgets', () => {
test('ProductList initial render stays under 16ms', async () => {
const durations: number[] = [];
await act(async () => {
render(
<Profiler
id="ProductList"
onRender={(_, phase, actualDuration) => {
if (phase === 'mount') durations.push(actualDuration);
}}
>
<ProductList items={generateMockItems(100)} />
</Profiler>
);
});
expect(durations[0]).toBeLessThan(16);
});
test('Counter update renders in under 5ms', async () => {
const durations: number[] = [];
const { getByRole } = render(
<Profiler
id="Counter"
onRender={(_, phase, actualDuration) => {
if (phase === 'update') durations.push(actualDuration);
}}
>
<Counter />
</Profiler>
);
await act(async () => {
getByRole('button').click();
});
expect(durations[0]).toBeLessThan(5);
});
});
These tests run in CI and catch performance regressions before they reach production. A pull request that makes ProductList initial render jump from 12ms to 28ms will fail the test suite.
Common Performance Antipatterns Found in the Wild
Antipattern 1: Object Creation Inside Render
// ❌ New object and array references on every render
function UserCard({ user }: { user: User }) {
return (
<Card
// New object every render → memo comparison fails
style={{ padding: 16, backgroundColor: '#f5f5f5', borderRadius: 8 }}
// New array every render → memo comparison fails
actions={[
{ label: 'Edit', handler: () => onEdit(user.id) },
{ label: 'Delete', handler: () => onDelete(user.id) },
]}
user={user}
/>
);
}
// ✅ Constant objects outside the component, dynamic ones in useMemo
const CARD_STYLE = { padding: 16, backgroundColor: '#f5f5f5', borderRadius: 8 };
function UserCard({ user }: { user: User }) {
const actions = useMemo(() => [
{ label: 'Edit', handler: () => onEdit(user.id) },
{ label: 'Delete', handler: () => onDelete(user.id) },
], [user.id]); // Rebuilds only when user.id changes
return <Card style={CARD_STYLE} actions={actions} user={user} />;
}
Measured impact: In a list of 100 UserCard components, this pattern causes 200 unnecessary object allocations and 100 unnecessary Card re-renders on every parent update (if Card is memoized). Without memoization it causes 100 unnecessary child re-renders.
Antipattern 2: useEffect State Cascades
// ❌ Three separate state updates = three render cycles
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsLoading(true); // Render 1: isLoading = true
fetchUser(userId).then(u => {
setUser(u); // Render 2: user = data
setIsLoading(false); // Render 3: isLoading = false
});
}, [userId]);
// Total: 3 renders per userId change, potentially more with posts
}
// ✅ Batch related state with useReducer
type State = { user: User | null; posts: Post[]; status: 'idle' | 'loading' | 'success' | 'error' };
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; user: User; posts: Post[] }
| { type: 'FETCH_ERROR' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'FETCH_START': return { ...state, status: 'loading' };
case 'FETCH_SUCCESS': return { user: action.user, posts: action.posts, status: 'success' };
case 'FETCH_ERROR': return { ...state, status: 'error' };
}
}
function UserProfile({ userId }: { userId: string }) {
const [state, dispatch] = useReducer(reducer, { user: null, posts: [], status: 'idle' });
useEffect(() => {
dispatch({ type: 'FETCH_START' }); // Render 1
Promise.all([fetchUser(userId), fetchPosts(userId)])
.then(([user, posts]) => dispatch({ type: 'FETCH_SUCCESS', user, posts })); // Render 2
}, [userId]);
// Total: 2 renders per userId change, no cascades
}
Antipattern 3: Monolithic Context Causing Global Re-renders
// ❌ One large context: any field change re-renders all consumers
const AppContext = createContext<AppState>({
user: null,
theme: 'light',
language: 'en',
notifications: [],
sidebarOpen: false,
currentProject: null,
permissions: [],
});
// Changing `sidebarOpen` re-renders components that only use `user`.
// Changing `notifications` re-renders the entire app tree.
// ✅ Split contexts by concern
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<'light' | 'dark'>('light');
const NotificationContext = createContext<Notification[]>([]);
const UIContext = createContext({ sidebarOpen: false });
// Now: changing sidebarOpen only re-renders UIContext consumers
// Changing theme only re-renders ThemeContext consumers
Measured impact in a medium-scale app: Splitting one large context into four domain-specific contexts reduced the average number of components re-rendering on a state change from 87 to 12 — an 86% reduction.
Antipattern 4: Long Lists Without Virtualization
// ❌ Rendering 10,000 rows: initial render ~2000ms, scrolling janky
function DataGrid({ rows }: { rows: DataRow[] }) {
return (
<div className="grid">
{rows.map(row => <DataRow key={row.id} data={row} />)}
</div>
);
}
// ✅ Virtualization: only render rows in the visible viewport (~20-30 at a time)
import { FixedSizeList } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
function DataGrid({ rows }: { rows: DataRow[] }) {
const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => (
<DataRow data={rows[index]} style={style} />
), [rows]);
return (
<AutoSizer>
{({ height, width }) => (
<FixedSizeList
height={height}
width={width}
itemCount={rows.length}
itemSize={50}
>
{Row}
</FixedSizeList>
)}
</AutoSizer>
);
}
// 10,000 rows: initial render ~15ms, scroll stays smooth
Antipattern 5: Derived State Recomputed Every Render
// ❌ Expensive transformation runs on every render, even unrelated ones
function ReportPage({ data, selectedMetric }: ReportPageProps) {
const [filterText, setFilterText] = useState('');
// This runs on every render including filterText changes
const aggregatedData = data
.filter(row => row.metric === selectedMetric)
.reduce<Record<string, number>>((acc, row) => {
acc[row.category] = (acc[row.category] ?? 0) + row.value;
return acc;
}, {});
return (/* ... */);
}
// ✅ Cache the expensive computation
function ReportPage({ data, selectedMetric }: ReportPageProps) {
const [filterText, setFilterText] = useState('');
// Only recomputes when data or selectedMetric changes
const aggregatedData = useMemo(() =>
data
.filter(row => row.metric === selectedMetric)
.reduce<Record<string, number>>((acc, row) => {
acc[row.category] = (acc[row.category] ?? 0) + row.value;
return acc;
}, {}),
[data, selectedMetric]
);
return (/* ... */);
}
A Complete Case Study: Kanban Board Drag Performance
The Problem
A project management application reports that dragging task cards feels "laggy." The drag animation stutters at around 15-20fps instead of the expected 60fps.
Step 1: Profile and Observe
Recording a drag gesture reveals:
Each mousemove event triggers a commit (~60 per second during drag):
KanbanBoard [████████████████████████ 48ms per commit]
├─ KanbanColumn × 4 [██ 5ms each]
│ └─ TaskCard × 20 [██ 2ms each] per column
└─ DragOverlay [█ 1ms]
Why KanbanColumn re-rendered: "props changed" → dragState
Why TaskCard re-rendered: "parent re-rendered"
Total: 80 TaskCards × 2ms = 160ms of component renders per second
At 60 mousemove events/second: 9,600ms of renders per second → severe jank
Step 2: Trace the Root Cause
// The problematic code
function KanbanBoard() {
const [dragState, setDragState] = useState<{
draggingId: string | null;
position: { x: number; y: number };
}>({
draggingId: null,
position: { x: 0, y: 0 },
});
const handleMouseMove = useCallback((e: MouseEvent) => {
setDragState(prev => ({
...prev,
position: { x: e.clientX, y: e.clientY }, // New position object every move
}));
// The spread creates a new dragState object reference every time
// All four KanbanColumn components receive the new dragState prop
// All 80 TaskCard components re-render due to parent re-render
}, []);
return (
<div onMouseMove={handleMouseMove}>
{columns.map(col => (
<KanbanColumn key={col.id} column={col} dragState={dragState} />
))}
<DragOverlay position={dragState.position} />
</div>
);
}
Root cause: position updates on every mouse movement create a new dragState object, invalidating all KanbanColumn props. All 80 TaskCards re-render even though neither their data nor the drag operation (only mouse position) changed.
Step 3: Apply the Fix
// Separated concerns: drag identity (low frequency) vs drag position (high frequency)
function KanbanBoard() {
const [draggingId, setDraggingId] = useState<string | null>(null);
// Position is kept separate — its changes don't affect KanbanColumn
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
const handleMouseMove = useCallback((e: MouseEvent) => {
// Only updates dragPosition — draggingId unchanged → reference stable
setDragPosition({ x: e.clientX, y: e.clientY });
}, []);
const handleDragStart = useCallback((id: string) => {
setDraggingId(id); // Changes infrequently (once per drag gesture)
}, []);
const handleDragEnd = useCallback(() => {
setDraggingId(null);
}, []);
return (
<div onMouseMove={handleMouseMove}>
{columns.map(col => (
// KanbanColumn only receives draggingId, not dragPosition
// dragPosition changes don't trigger KanbanColumn re-renders
<MemoKanbanColumn
key={col.id}
column={col}
draggingId={draggingId}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
/>
))}
{/* DragOverlay alone receives dragPosition — only this re-renders per mousemove */}
<DragOverlay taskId={draggingId} position={dragPosition} />
</div>
);
}
const MemoKanbanColumn = React.memo(KanbanColumn);
Step 4: Verify With Profiler
After optimization — same drag gesture recording:
Each mousemove commit:
KanbanBoard [█ 2ms] — only setDragPosition triggers
DragOverlay [█ 1ms] — follows mouse
KanbanColumn (×4): [gray — bailed out, not re-rendered]
TaskCard (×80): [gray — bailed out, not re-rendered]
Total commit time: 3ms (down from 48ms)
Frame time improvement: 94% reduction
Drag animation: smooth 60fps
The Profiler before/after comparison is the proof. Not a benchmark, not "it feels faster" — an objective measurement showing 48ms commits reduced to 3ms.
Web Vitals and React Performance
React rendering performance connects directly to the three metrics that determine your Core Web Vitals score.
LCP (Largest Contentful Paint) — Target: < 2.5s
React's initial render blocks LCP timing. JavaScript must download, parse, and execute before React can produce any DOM. Optimization strategies:
- Route-level code splitting to reduce initial JS payload
- Server-side rendering (SSR) or React Server Components to deliver HTML before JS executes
- Avoid expensive synchronous computations in the initial render path
INP (Interaction to Next Paint) — Target: < 200ms
This is the Web Vital most directly affected by React rendering performance. Every user interaction (click, keypress) must produce a visible update within 200ms. Slow React renders directly inflate INP.
// useTransition defers non-critical updates without blocking the interaction response
function SearchPage() {
const [inputValue, setInputValue] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Immediate: input value must update synchronously for good UX
setInputValue(value);
// Deferred: search results can wait — they're not blocking the input response
startTransition(() => {
setSearchQuery(value);
});
};
return (
<>
<input value={inputValue} onChange={handleChange} />
{isPending && <div className="searching-indicator" aria-live="polite">Searching...</div>}
<SearchResults query={searchQuery} />
</>
);
}
With useTransition, the browser can interrupt the search results render to process the next keypress. INP improvements of 60-80% are typical for search-heavy interfaces.
// useDeferredValue: similar concept, for values not callbacks
function FilteredList({ items, heavyFilter }: FilteredListProps) {
const [filterText, setFilterText] = useState('');
// deferredText lags behind filterText during rapid typing
// The list re-renders only when the browser has idle time
const deferredFilter = useDeferredValue(filterText);
const filteredItems = useMemo(
() => items.filter(item => heavyFilter(item, deferredFilter)),
[items, deferredFilter]
);
return (
<>
<input value={filterText} onChange={e => setFilterText(e.target.value)} />
<ul>
{filteredItems.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
</>
);
}
CLS (Cumulative Layout Shift) — Target: < 0.1
React-specific CLS sources:
- Components switching from loading to loaded state with different dimensions
- Images without explicit dimensions causing reflow when they load
- Dynamically injected content pushing existing content down
The Suspense skeleton screen strategy (Chapter 17) directly addresses the first cause. Skeleton screens that match the dimensions of the final content prevent layout shifts when data arrives.
// Measure and reserve space to prevent CLS
function AvatarGroup({ userIds }: { userIds: string[] }) {
return (
// Reserve exact height of the final content
<div style={{ height: 40, display: 'flex' }}>
<Suspense
fallback={
// Same height, same layout as the loaded state
<div style={{ height: 40, width: userIds.length * 32 }} className="skeleton" />
}
>
<LazyAvatarGroup userIds={userIds} />
</Suspense>
</div>
);
}
Building a Performance Culture
The technical tools are only half the equation. Performance degradation in real applications is usually gradual — each PR introduces a small regression that's invisible in isolation but accumulates into significant user pain.
Sustainable performance engineering requires:
- Performance budgets in CI: Profiler API tests that fail on measurable regressions
- Real-user monitoring: Track Core Web Vitals from actual user sessions, not just lab measurements
- Profiler-first debugging discipline: When someone says "it feels slow," the first response is to record and profile, not to guess and add
useMemo - Documentation of performance-sensitive paths: Mark components that have known performance constraints so future developers know to measure before modifying them
Summary
React DevTools Profiler provides flame graphs (full render timing landscape) and ranked charts (fastest path to the slowest component). The most diagnostic information is why a component re-rendered — which specific prop, state, or hook changed. The Profiler API enables programmatic measurement for CI regression testing and production monitoring. The five most common performance antipatterns in real codebases: creating objects inside render, useEffect state cascades, monolithic contexts, unvirtualized long lists, and recomputing derived state on every render. The standard optimization workflow is: record → identify hot spots → trace root cause → apply minimal fix → record again to verify. React performance connects directly to LCP (reduce initial JS), INP (use useTransition/useDeferredValue for heavy renders), and CLS (skeleton screens with correct dimensions). Measure first. Always.