Skip to content
Back to Blog
ReactPerformanceProfilerOptimizationReact DevTools

React `<Profiler>`: Measure Before You Memoize

Learn how to use React's built-in Profiler component to get per-commit render timings for any subtree, so you know which components are slow and whether memoization is actually paying for itself, before reaching for useMemo.

14 min read

TL;DR: React ships with a <Profiler> component that fires a callback with render durations every time a subtree commits. It takes five minutes to wire up and tells you actualDuration (how long this render actually took), baseDuration (React's estimate of how long it would take with no memoization at all), and phase (mount, update, or nested-update). The gap between the first two numbers is your memoization ROI. If they're identical, memoizing changes nothing.


I've watched the same debugging session play out a dozen times. App feels sluggish. Developer opens React DevTools, sees yellow bars in the flamegraph, starts wrapping things in React.memo and useMemo. Some of the yellow bars go away. Some come back. A stale closure appears two weeks later. Nobody's sure what actually improved.

The flamegraph is genuinely useful, but it requires you to record, interact, stop, and decode the result every time. For targeted measurement ("is this specific component the bottleneck, and by how much?"), there's a better tool built directly into React. Most developers don't know it exists.

It's called <Profiler>, it's been a stable API since React 16.9 (it shipped as the experimental React.unstable_Profiler back in 16.4), and it takes about five lines to use.


The API Is Smaller Than You Think

import { Profiler } from 'react';
 
<Profiler id="sidebar" onRender={onRender}>
  <Sidebar />
</Profiler>

That's it. Wrap any subtree in <Profiler>, give it an id, and pass an onRender callback. React calls that callback every time the subtree commits a render.

The callback signature:

function onRender(
  id: string,           // the "id" prop you passed
  phase: 'mount' | 'update' | 'nested-update',
  actualDuration: number,   // ms to render this commit
  baseDuration: number,     // estimated ms if zero memoization
  startTime: number,        // when React began rendering
  commitTime: number        // when React committed to the DOM
) {}

Six parameters. Three of them are the interesting ones.

If you find an older article showing a seventh interactions argument, ignore it. That was part of the experimental interaction tracing API and it was removed in React 18. React also exports the callback type as ProfilerOnRenderCallback, so in TypeScript you can type your handler with that instead of spelling out all six parameters.


What the Three Important Numbers Mean

Here's a visual of what a single render commit looks like:

Component tree commit
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 
  id:             "sidebar"
  phase:          "update"          ← mount | update | nested-update
 
  baseDuration:   48ms              ← worst case, no memoization
  actualDuration:  6ms              ← what actually ran this commit
 
  startTime:      1240.5ms          ← timestamp: React started rendering
  commitTime:     1246.8ms          ← timestamp: DOM updated
 
  memoization ROI: 42ms saved (88%)
 
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

actualDuration is the time React spent rendering the <Profiler> and its descendants for this specific commit. If this is high, something rendered slowly. It should drop noticeably after the initial mount, because most descendants only need to re-render when their own props change.

baseDuration is an estimate of how long the subtree would take if every component re-rendered from scratch, no memoization. React computes it by summing the most recent render duration of every component in the tree, which makes it a worst-case number rather than a measurement. If actualDuration is significantly lower than baseDuration, your memoization is working. If they're close together, you're paying the overhead of useMemo for almost nothing.

phase tells you whether this was the initial mount or a re-render. Expensive mounts are a different problem from expensive updates. A component that takes 80ms to mount but 2ms to update is fine. A component that takes 80ms on every update is not.

The naming of the first two trips people up. I spent longer than I'd like to admit assuming "actual" and "base" were synonyms for "real" and "estimated." They're not. Think of it as: baseDuration is "what this would cost without any of your optimization work." The delta is the payoff from that work.

The two timestamps matter less day to day, but commitTime has one useful property: every <Profiler> in the same commit receives the same value. If you have several profilers on a page, that's the key you group their entries by to reconstruct one commit.


Where to Put It

<Profiler> wraps subtrees, not the whole app. Place it around the part you're suspicious about.

// measuring two independent parts of the page
function App() {
  return (
    <>
      <Profiler id="sidebar" onRender={onRender}>
        <Sidebar />
      </Profiler>
 
      <Profiler id="feed" onRender={onRender}>
        <Feed />
      </Profiler>
    </>
  );
}
App
├── [Profiler: "sidebar"]
│   └── Sidebar
│       ├── NavItem
│       ├── NavItem
│       └── UserAvatar

└── [Profiler: "feed"]
    └── Feed
        ├── FeedItem
        ├── FeedItem
        └── FeedItem

The onRender callback fires independently for each <Profiler>. Both pass their id string so you can tell them apart. You can reuse the same callback function for all of them.

You can nest <Profiler> components too. A parent profiler reports the entire subtree. A nested child profiler reports only its slice:

<Profiler id="content" onRender={onRender}>
  <Content>
    <Profiler id="editor" onRender={onRender}>
      <Editor />
    </Profiler>
    <Preview />  {/* not separately profiled */}
  </Content>
</Profiler>

When Editor renders, you get two callbacks: one for "editor" with just its cost, and one for "content" with the full subtree cost. That's how you isolate whether the bottleneck is the editor specifically or the content container around it.

Resist the urge to wrap everything, though. <Profiler> is lightweight but not free: every instance adds CPU and memory overhead to the very thing you're trying to measure. Two or three around the areas you actually suspect will tell you more than twenty scattered across the app.


The Simplest Useful Logger

Before building anything visual, start here:

function onRender(
  id: string,
  phase: string,
  actualDuration: number,
  baseDuration: number
) {
  console.log(`[${id}] ${phase} | actual: ${actualDuration.toFixed(1)}ms, base: ${baseDuration.toFixed(1)}ms`);
}

Run your app, trigger an interaction, and read the output:

[sidebar] mount  | actual: 12.4ms, base: 12.4ms
[feed]    mount  | actual: 38.1ms, base: 38.1ms
[feed]    update | actual: 35.9ms, base: 38.1ms   ← memoization saved ~2ms
[feed]    update | actual: 34.8ms, base: 38.1ms
[sidebar] update | actual:  1.2ms, base: 12.4ms   ← memoization saved ~11ms

Note that on mount the two numbers match. That's expected: nothing has rendered before, so the worst case and the real case are the same render. The interesting rows are the updates.

A few things jump out immediately from output like this. The feed is expensive on every update, and memoization is barely helping (35ms against 38ms). The sidebar's memoization is working well (1.2ms against 12.4ms). That's your prioritization right there: fix the feed first, the sidebar can wait.


Build a Visual Profiler Overlay

The console gets noisy fast. An overlay on the page is nicer, but there's a trap here that's worth naming before the code.

Do not keep the entries in useState in a component that sits above the <Profiler>. The onRender callback runs on every commit. If it calls a setter that re-renders the profiled subtree, that subtree commits again, which calls onRender again, and you've built an infinite loop out of your performance tool.

The fix is to keep the log in a store outside the React tree and subscribe to it only from the overlay, which lives outside every <Profiler>. useSyncExternalStore is exactly the built-in for this.

// components/dev/profiler-store.ts
import { useSyncExternalStore } from 'react';
import type { ProfilerOnRenderCallback } from 'react';
 
export type RenderEntry = {
  id: string;
  phase: string;
  actualDuration: number;
  baseDuration: number;
  commitTime: number;
};
 
// One module-level log, so the overlay never needs a provider. The ceiling:
// it's global. If you ever need two independent overlays, move it into context.
let entries: RenderEntry[] = [];
const listeners = new Set<() => void>();
 
export const onRender: ProfilerOnRenderCallback = (
  id, phase, actualDuration, baseDuration, _startTime, commitTime
) => {
  entries = [{ id, phase, actualDuration, baseDuration, commitTime }, ...entries].slice(0, 20);
  listeners.forEach(notify => notify());
};
 
function subscribe(notify: () => void) {
  listeners.add(notify);
  return () => {
    listeners.delete(notify);
  };
}
 
const getSnapshot = () => entries;
 
export function useRenderEntries() {
  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

Because onRender is a plain module export rather than a hook result, it's referentially stable for free, which means the <Profiler> never re-renders its children just because the callback identity changed.

Now the overlay itself:

// components/dev/profiler-overlay.tsx
'use client';
 
import { useRenderEntries } from './profiler-store';
 
export function ProfilerOverlay() {
  const entries = useRenderEntries();
 
  if (entries.length === 0) return null;
 
  return (
    <div style={{
      position: 'fixed', bottom: 16, right: 16, zIndex: 9999,
      background: '#0f0f0f', border: '1px solid #333',
      borderRadius: 8, padding: '12px 16px', width: 340,
      fontFamily: 'monospace', fontSize: 12, color: '#e5e5e5',
    }}>
      <div style={{ marginBottom: 8, color: '#888', fontWeight: 600 }}>
        Profiler: last {entries.length} commits
      </div>
      {entries.map((e, i) => {
        const saved = e.baseDuration - e.actualDuration;
        const isSlow = e.actualDuration > 16; // longer than one 60fps frame
 
        return (
          <div key={`${e.commitTime}-${e.id}-${i}`} style={{
            display: 'flex', justifyContent: 'space-between',
            padding: '4px 0', borderBottom: '1px solid #222',
            color: isSlow ? '#f97316' : '#e5e5e5',
          }}>
            <span>
              <span style={{ color: '#60a5fa' }}>{e.id}</span>
              {' '}
              <span style={{ color: '#888' }}>{e.phase}</span>
            </span>
            <span>
              {e.actualDuration.toFixed(1)}ms
              {saved > 1 && (
                <span style={{ color: '#4ade80', marginLeft: 6 }}>
                  {saved.toFixed(1)}ms saved
                </span>
              )}
            </span>
          </div>
        );
      })}
    </div>
  );
}

Wire it up:

// in your page or layout (dev only)
'use client';
 
import { Profiler } from 'react';
import { onRender } from '@/components/dev/profiler-store';
import { ProfilerOverlay } from '@/components/dev/profiler-overlay';
 
export default function Page() {
  return (
    <>
      <Profiler id="sidebar" onRender={onRender}>
        <Sidebar />
      </Profiler>
      <Profiler id="feed" onRender={onRender}>
        <Feed />
      </Profiler>
 
      {process.env.NODE_ENV === 'development' && <ProfilerOverlay />}
    </>
  );
}

Two things to notice in that snippet. The overlay is a sibling of the profilers, never a child, so its own re-renders stay out of the measurement. And the whole thing needs 'use client': onRender is a function, and functions can't cross the server/client boundary in the App Router, so a Server Component can't render a <Profiler>.

What this looks like at runtime:

┌─────────────────────────────────────────────┐
│ Profiler: last 6 commits                    │
├─────────────────────────────────────────────┤
│ feed       update   35.2ms                  │ ← orange (> 16ms)
│ sidebar    update    1.1ms   11.3ms saved   │
│ feed       update   34.8ms                  │ ← orange
│ feed       update   36.1ms                  │ ← orange
│ sidebar    mount    12.4ms                  │
│ feed       mount    38.1ms                  │ ← orange
└─────────────────────────────────────────────┘

The 16ms threshold for orange is one frame at 60fps (16.7ms, rounded down). It's a generous line to draw, since that budget covers style, layout, paint and everything else the browser has to do, not just your render. The feed is over it on every update. The sidebar is fine. Now you're not guessing.


Using phase to Separate Mount from Update Problems

The phase parameter catches something the overall timing misses. A component that renders in 60ms at mount and 2ms on updates is doing the right thing: expensive one-time setup followed by cheap incremental updates. A component that renders in 60ms on every update is the problem.

function onRender(id: string, phase: string, actualDuration: number) {
  if (phase === 'update' && actualDuration > 16) {
    console.warn(`[SLOW UPDATE] ${id}: ${actualDuration.toFixed(1)}ms`);
  }
}

Filter on phase === 'update' and you only see the re-render performance. Mount performance is usually less important unless your initial load is the complaint.

nested-update is the third value, added in React 18, and it's the most interesting one. It means the render was caused by a state update scheduled from a layout effect: useLayoutEffect, or componentDidMount / componentDidUpdate in a class. React processes those synchronously, before it lets the browser paint, so that a component can measure and adjust its layout without the user seeing a shift.

That's the whole point of useLayoutEffect, and it's the right tool for positioning a tooltip. But it also means the work is blocking paint. This is what people mean by a cascading update, and seeing nested-update in your log is a prompt to ask one question: does this effect actually need to run before paint?

function onRender(id: string, phase: string, actualDuration: number) {
  if (phase === 'nested-update') {
    console.warn(`[BLOCKS PAINT] ${id}: ${actualDuration.toFixed(1)}ms`);
  }
}

If the effect isn't reading or writing layout (attaching listeners, logging an impression, syncing analytics), move it to useEffect. It runs after paint and stops delaying the frame.


<Profiler> vs React DevTools Profiler

They solve different problems:

<Profiler> componentDevTools Profiler tab
SetupCode, wrap the subtreeGUI, click Record
DataStreamed, every commitCaptured per session
OutputYour callback: log, aggregate, displayFlamegraph, ranked chart
Good forContinuous measurement, CI, custom dashboardsExploratory profiling, seeing the full tree
OverheadAlways on (dev)Only while recording

I reach for the <Profiler> component when I have a specific hypothesis: "I think the feed re-renders too often and too slowly." I want a number on every render, not a snapshot from one recording session. The DevTools tab is better when I don't know where the problem is and need to explore the whole tree visually.

The two complement each other. Use DevTools to find the suspicious area, then add a <Profiler> to instrument it precisely.


Caveats Worth Reading

<Profiler> is disabled in production builds by default. The instrumentation costs real CPU and memory, so the standard production build doesn't carry it and your onRender never fires.

If you need production numbers, React ships a separate profiling build. You enable it by aliasing react-dom/client to react-dom/profiling at build time rather than editing imports by hand:

// vite.config.js
resolve: {
  alias: { 'react-dom/client': 'react-dom/profiling' },
}

Check your framework first, though, because most of them wire this up for you. In Next.js it's one line:

// next.config.js
module.exports = { reactProductionProfiling: true };

(If you find an older post telling you to also alias scheduler/tracing-profiling, skip it. That was for the interaction tracing API, which React removed in 18.)

Development timings are inflated. Dev builds run extra validation, and under <StrictMode> React deliberately double-invokes render functions to surface side effects. Both land inside actualDuration. So treat dev numbers as a ranking, not a measurement: they're great for spotting which component is disproportionately expensive and which renders shouldn't be happening at all, and bad for quoting an absolute millisecond figure. Throttling the CPU 4x to 6x in Chrome DevTools is usually enough to find the bottleneck without setting up a profiling build.

Don't ship the wrappers out of habit. In a normal production build the callback never fires, but each <Profiler> is still a node React has to walk. If you want to leave the instrumentation in the source, swap the component out at build time and let the bundler dead-code eliminate it:

// components/dev/dev-profiler.tsx
import { Profiler } from 'react';
import type { ProfilerProps } from 'react';
 
export const DevProfiler =
  process.env.NODE_ENV === 'development'
    ? Profiler
    : ({ children }: ProfilerProps) => children;

Use <DevProfiler> everywhere instead of <Profiler> and production gets a plain passthrough.


The Decision Rule

When you suspect a performance problem in a specific part of the UI:

  1. Wrap it in <Profiler>, log actualDuration and baseDuration
  2. If actualDuration is high and close to baseDuration: the render itself is slow. Fix the computation, not the frequency.
  3. If actualDuration is high but much lower than baseDuration: memoization is working but renders are still frequent. Fix why the component is re-rendering at all: colocation, composition, context splitting.
  4. If actualDuration is low: this isn't your bottleneck. Move on.

The gap between baseDuration and actualDuration is the answer to "is my memoization doing anything?" If they're within 2ms of each other on a component wrapped in React.memo and useMemo, you're maintaining dependency arrays for nothing.

Measure first. The number tells you what to do.