Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
// 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.
// ---- 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:
Profiler identifies OrderTable as the bottleneck (160ms vs 12ms for StatsPanel)onRender callback logs actual vs base duration, showing how much memoization savesWhy 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 laneProfilerOnRenderCallback is the type for the onRender prop. Import it from "react".Profiler component accepts id: string, onRender: ProfilerOnRenderCallback, and children: ReactNode.phase parameter is typed as "mount" | "update" | "nested-update".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/profiling build for production profiling. Add "profiling": true to 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 Profiler component is a no-op in production builds. Fix: Use the profiling build: import ReactDOM from "react-dom/profiling" and set resolve.alias in 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 baseDuration in the Profiler callback tells you how long the render would take without any memoization. If actualDuration is close to baseDuration, memoization is not helping. Fix: Compare both values. If the difference is small, consider removing memoization to reduce complexity.
| 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 |
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.
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>If both values are close, memoization is not helping and can be removed to reduce complexity.
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.
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.
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.
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.
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.
import { type ProfilerOnRenderCallback } from "react";The phase parameter is typed as "mount" | "update" | "nested-update".
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.
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.
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.
Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥