React DevTools Profiler — Measure render performance and find bottlenecks
Recipe
// Step 1: Install React DevTools browser extension
// Chrome: chrome://extensions -> search "React Developer Tools"
// Firefox: Add-ons -> search "React Developer Tools"
// Step 2: Open DevTools -> Profiler tab -> Click Record -> Interact -> Stop
// Step 3: Use the React Profiler API for production measurement
import { Profiler, type ProfilerOnRenderCallback } from "react";
const onRender: ProfilerOnRenderCallback = (
id, // "Dashboard"
phase, // "mount" or "update"
actualDuration, // Time spent rendering (ms)
baseDuration, // Estimated time without memoization (ms)
startTime,
commitTime
) => {
if (actualDuration > 16) {
// Longer than one frame at 60fps
console.warn(`Slow render: ${id} took ${actualDuration.toFixed(1)}ms`);
}
};
function App() {
return (
<Profiler id="Dashboard" onRender={onRender}>
<Dashboard />
</Profiler>
);
}When to reach for this: Before any optimization work. Profile first, then fix. Without profiling, you might optimize components that are already fast while missing the actual bottleneck.
Working Example
// ---- Profiling a slow dashboard and fixing the bottleneck ----
// BEFORE: Dashboard renders in ~180ms — Profiler shows OrderTable is the culprit
import { Profiler, useState, useMemo, memo, useCallback } from "react";
import type { ProfilerOnRenderCallback } from "react";
interface Order {
id: string;
customer: string;
total: number;
status: "pending" | "shipped" | "delivered";
date: string;
}
// Performance logger — sends slow renders to your analytics
const perfLog: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration) => {
// Log all renders during development
console.table({
component: id,
phase,
actual: `${actualDuration.toFixed(1)}ms`,
base: `${baseDuration.toFixed(1)}ms`,
saved: `${(baseDuration - actualDuration).toFixed(1)}ms`,
});
// In production, only report slow renders
if (process.env.NODE_ENV === "production" && actualDuration > 50) {
fetch("/api/perf", {
method: "POST",
body: JSON.stringify({ id, phase, actualDuration, baseDuration }),
});
}
};
// Profiled dashboard — wraps each section independently
function Dashboard() {
const [orders, setOrders] = useState<Order[]>(generateOrders(1000));
const [statusFilter, setStatusFilter] = useState<string>("all");
const [refreshCount, setRefreshCount] = useState(0);
return (
<div className="grid grid-cols-3 gap-4">
<Profiler id="StatsPanel" onRender={perfLog}>
<StatsPanel orders={orders} />
</Profiler>
<Profiler id="OrderTable" onRender={perfLog}>
<OrderTable
orders={orders}
statusFilter={statusFilter}
onFilterChange={setStatusFilter}
/>
</Profiler>
<Profiler id="ActivityFeed" onRender={perfLog}>
<ActivityFeed refreshCount={refreshCount} />
</Profiler>
<button onClick={() => setRefreshCount((c) => c + 1)}>Refresh</button>
</div>
);
}
// BEFORE: StatsPanel — recomputes on every render (~12ms)
function StatsPanel({ orders }: { orders: Order[] }) {
const revenue = orders.reduce((sum, o) => sum + o.total, 0);
const pending = orders.filter((o) => o.status === "pending").length;
const shipped = orders.filter((o) => o.status === "shipped").length;
return (
<div className="grid grid-cols-3 gap-2">
<div>Revenue: ${revenue.toLocaleString()}</div>
<div>Pending: {pending}</div>
<div>Shipped: {shipped}</div>
</div>
);
}
// AFTER: StatsPanel — memoized computation (~0.1ms on re-render)
const OptimizedStatsPanel = memo(function StatsPanel({ orders }: { orders: Order[] }) {
const stats = useMemo(() => ({
revenue: orders.reduce((sum, o) => sum + o.total, 0),
pending: orders.filter((o) => o.status === "pending").length,
shipped: orders.filter((o) => o.status === "shipped").length,
}), [orders]);
return (
<div className="grid grid-cols-3 gap-2">
<div>Revenue: ${stats.revenue.toLocaleString()}</div>
<div>Pending: {stats.pending}</div>
<div>Shipped: {stats.shipped}</div>
</div>
);
});
// BEFORE: OrderTable — filters + renders 1000 rows on every parent render (~160ms)
function OrderTable({
orders,
statusFilter,
onFilterChange,
}: {
orders: Order[];
statusFilter: string;
onFilterChange: (status: string) => void;
}) {
const filtered = orders.filter(
(o) => statusFilter === "all" || o.status === statusFilter
);
return (
<table>
<thead>
<tr>
<th>Customer</th>
<th>Total</th>
<th>
<select value={statusFilter} onChange={(e) => onFilterChange(e.target.value)}>
<option value="all">All</option>
<option value="pending">Pending</option>
<option value="shipped">Shipped</option>
<option value="delivered">Delivered</option>
</select>
</th>
</tr>
</thead>
<tbody>
{filtered.map((order) => (
<tr key={order.id}>
<td>{order.customer}</td>
<td>${order.total}</td>
<td>{order.status}</td>
</tr>
))}
</tbody>
</table>
);
}
// AFTER: OrderTable — memoized filtering + virtualized (~3ms on re-render)
const OptimizedOrderTable = memo(function OrderTable({
orders,
statusFilter,
onFilterChange,
}: {
orders: Order[];
statusFilter: string;
onFilterChange: (status: string) => void;
}) {
const filtered = useMemo(
() => orders.filter((o) => statusFilter === "all" || o.status === statusFilter),
[orders, statusFilter]
);
// Show only first 50 rows + virtualize the rest
const visible = filtered.slice(0, 50);
return (
<table>
<thead>
<tr>
<th>Customer</th>
<th>Total</th>
<th>
<select value={statusFilter} onChange={(e) => onFilterChange(e.target.value)}>
<option value="all">All</option>
<option value="pending">Pending</option>
<option value="shipped">Shipped</option>
<option value="delivered">Delivered</option>
</select>
</th>
</tr>
</thead>
<tbody>
{visible.map((order) => (
<tr key={order.id}>
<td>{order.customer}</td>
<td>${order.total}</td>
<td>{order.status}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={3}>
Showing {visible.length} of {filtered.length} orders
</td>
</tr>
</tfoot>
</table>
);
});What this demonstrates:
- Wrapping each dashboard section in its own
Profileridentifies OrderTable as the bottleneck (160ms vs 12ms for StatsPanel) - The
onRendercallback logs actual vs base duration, showing how much memoization saves - After optimization: Dashboard render drops from ~180ms to under 15ms (92% reduction)
- Production profiling sends slow renders to an analytics endpoint for monitoring
Deep Dive
How It Works
- Flame Chart — Shows a tree of components with render duration. Wider bars mean slower renders. Gray bars indicate components that did not render (skipped by memo). Look for the widest bars to find bottlenecks.
- Ranked Chart — Lists components sorted by render time, most expensive first. This is the fastest way to identify which components to optimize.
- Component render count — Click any component in the Profiler to see how many times it rendered during the recording, and the duration of each render. High render counts with consistent duration suggest unnecessary re-renders.
- "Why did this render?" — Enable in React DevTools settings under Profiler. When enabled, clicking a component in the profile shows which props or state changed to trigger the render.
- Highlight updates — Enable "Highlight updates when components render" in the Components tab settings. Components flash with a colored border on each render. The color indicates render frequency: blue (rare) to red (frequent).
- Commit selector — The bar chart at the top of the Profiler represents individual commits. Taller bars are slower commits. Click each to see what rendered in that commit.
Variations
Why Did This Render (third-party library):
// npm install @welldone-software/why-did-you-render
// wdyr.ts — import before React
import React from "react";
if (process.env.NODE_ENV === "development") {
const whyDidYouRender = require("@welldone-software/why-did-you-render");
whyDidYouRender(React, {
trackAllPureComponents: true,
logOnDifferentValues: true,
});
}
// Mark specific components for tracking
function ExpensiveList({ items }: { items: Item[] }) {
return <ul>{items.map((item) => <li key={item.id}>{item.name}</li>)}</ul>;
}
ExpensiveList.whyDidYouRender = true;
// Console output:
// ExpensiveList: Re-rendered because of props changes:
// items: [{id: 1}] !== [{id: 1}] (different reference, same content)Custom performance marks for production:
function useRenderPerfMark(componentName: string) {
const renderCount = useRef(0);
useEffect(() => {
renderCount.current += 1;
performance.mark(`${componentName}-render-${renderCount.current}`);
if (renderCount.current > 1) {
performance.measure(
`${componentName}-render`,
`${componentName}-render-${renderCount.current - 1}`,
`${componentName}-render-${renderCount.current}`
);
}
});
}
function Dashboard() {
useRenderPerfMark("Dashboard");
return <div>{/* ... */}</div>;
}
// View in Chrome DevTools -> Performance tab -> Timings laneTypeScript Notes
ProfilerOnRenderCallbackis the type for theonRenderprop. Import it from"react".- The
Profilercomponent acceptsid: string,onRender: ProfilerOnRenderCallback, andchildren: ReactNode. - The
phaseparameter is typed as"mount" | "update" | "nested-update".
Gotchas
-
Profiling in development mode — React development mode adds significant overhead (component stack traces, double-invoked effects). Render times in dev are 2-5x slower than production. Fix: Use
react-dom/profilingbuild for production profiling. Add"profiling": trueto Next.js config. -
DevTools slowing down the app — The React DevTools extension itself adds overhead, especially with the Components tab open. Fix: Close the Components tab while profiling. Only use the Profiler tab during recording.
-
Profiler API stripped in production — By default, the
Profilercomponent is a no-op in production builds. Fix: Use the profiling build:import ReactDOM from "react-dom/profiling"and setresolve.aliasin your bundler config. -
Measuring single renders instead of interactions — A single profile recording may not capture the actual user interaction pattern. Fix: Record entire user flows (page load, search, navigation) and look at the full timeline.
-
Ignoring the base duration — The
baseDurationin the Profiler callback tells you how long the render would take without any memoization. IfactualDurationis close tobaseDuration, memoization is not helping. Fix: Compare both values. If the difference is small, consider removing memoization to reduce complexity.
Alternatives
| Approach | Trade-off |
|---|---|
| React DevTools Profiler | Visual, interactive; development only without profiling build |
Profiler API | Programmatic, works in production; requires profiling build |
| Why Did You Render | Shows exact prop changes causing re-renders; dev only, adds overhead |
| Chrome Performance tab | Full browser timeline including layout and paint; lower level |
performance.mark / performance.measure | Native browser API; requires manual instrumentation |
| Lighthouse | Automated auditing; focuses on page load, not runtime interactions |
FAQs
What is the first step before optimizing any React component's performance?
Profile first. Use the React DevTools Profiler to record interactions and identify which components are actually slow. Without profiling, you may optimize components that are already fast while missing the real bottleneck.
How do you use the React Profiler API to log slow renders?
import { Profiler, type ProfilerOnRenderCallback } from "react";
const onRender: ProfilerOnRenderCallback = (
id, phase, actualDuration, baseDuration
) => {
if (actualDuration > 16) {
console.warn(`Slow render: ${id} took ${actualDuration.toFixed(1)}ms`);
}
};
<Profiler id="Dashboard" onRender={onRender}>
<Dashboard />
</Profiler>What is the difference between actualDuration and baseDuration in the Profiler callback?
- actualDuration: time spent rendering with memoization active
- baseDuration: estimated time without any memoization
If both values are close, memoization is not helping and can be removed to reduce complexity.
How do you identify the most expensive component using the Profiler?
Use the Ranked Chart view in the DevTools Profiler. It lists components sorted by render time, most expensive first. This is the fastest way to find which components to optimize.
What does "Why did this render?" show in React DevTools?
Enable it in React DevTools settings under Profiler. When enabled, clicking a component in the profile shows which specific props or state changed to trigger the render. This pinpoints whether a re-render is necessary or wasted.
Gotcha: Why are render times 2-5x slower in development mode?
React development mode adds significant overhead including component stack traces and double-invoked effects (Strict Mode). Always use the profiling build (react-dom/profiling) for accurate production measurements.
Gotcha: Does the React DevTools extension itself affect performance?
Yes. The DevTools extension adds overhead, especially with the Components tab open. Close the Components tab while profiling and only use the Profiler tab during recording to get more accurate results.
How do you enable profiling in a production Next.js build?
The Profiler component is a no-op in production by default. To enable it, use the profiling build of React DOM and add "profiling": true to your Next.js config.
What is the correct TypeScript type for the Profiler onRender callback?
import { type ProfilerOnRenderCallback } from "react";The phase parameter is typed as "mount" | "update" | "nested-update".
How does the Why Did You Render library complement React DevTools?
It logs the exact prop changes causing re-renders to the console, including cases where props have different references but identical content. Mark components with Component.whyDidYouRender = true for tracking.
Should you profile single renders or entire user flows?
Profile entire user flows (page load, search, navigation). A single render recording may not capture the actual interaction pattern. Look at the full timeline across multiple commits to identify patterns.
How can you use performance.mark for custom render tracking in production?
useEffect(() => {
renderCount.current += 1;
performance.mark(`${name}-render-${renderCount.current}`);
if (renderCount.current > 1) {
performance.measure(`${name}-render`,
`${name}-render-${renderCount.current - 1}`,
`${name}-render-${renderCount.current}`
);
}
});View results in Chrome DevTools Performance tab under the Timings lane.
Related
- Preventing Re-renders — Fixing the re-render problems that profiling reveals
- Memoization — Applying useMemo, useCallback, and memo where profiling shows need
- Core Web Vitals — Production performance metrics beyond component rendering
- Performance Checklist — Systematic audit process using profiling tools