React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

profilingreact-devtoolsflame-chartperformancewhy-did-this-renderprofiler-api

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 Profiler identifies OrderTable as the bottleneck (160ms vs 12ms for StatsPanel)
  • The onRender callback 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 lane

TypeScript Notes

  • ProfilerOnRenderCallback is the type for the onRender prop. Import it from "react".
  • The Profiler component accepts id: string, onRender: ProfilerOnRenderCallback, and children: ReactNode.
  • The phase parameter 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/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.

Alternatives

ApproachTrade-off
React DevTools ProfilerVisual, interactive; development only without profiling build
Profiler APIProgrammatic, works in production; requires profiling build
Why Did You RenderShows exact prop changes causing re-renders; dev only, adds overhead
Chrome Performance tabFull browser timeline including layout and paint; lower level
performance.mark / performance.measureNative browser API; requires manual instrumentation
LighthouseAutomated 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.