<Profiler>
Measures how often a React tree renders and how long each of its commits costs.
<Profiler id="App" onRender={onRender}>
<App />
</Profiler>
function onRender(id, phase, actualDuration, baseDuration, startTime, commitTime) {
// Aggregate or log render timings...
}- Added in React 16.9.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$id | string | A string identifying the part of the UI you are measuring. It is passed back as the first argument to onRender, so several profilers in one app stay distinguishable. |
$onRender | (id, phase, actualDuration, baseDuration, startTime, commitTime) => void | Called every time components within the profiled tree commit an update, with information about what was rendered and how long it took. |
$children | ReactNode | The subtree whose render work is measured. |
Return value
ReactNodeIts children, unchanged. It renders no DOM of its own; only the timing callback is added.
Changelog
- PHP 16.9.0Added as a stable API for gathering render measurements programmatically.
- PHP 18.0.0The trailing `interactions` argument was removed from onRender, and "nested-update" joined "mount" and "update" as a possible phase.
Wrapping a subtree adds a callback that fires on every commit inside it, reporting which tree committed, why, and how expensive it was. Unlike the DevTools profiler this runs in your own code, so the numbers can go to an analytics endpoint or a regression test instead of a flame chart.
Reading onRender's arguments
| Argument | Meaning |
|---|---|
id | The id prop of the tree that just committed. |
phase | "mount", "update", or "nested-update" when the update was scheduled from inside a commit. |
actualDuration | Milliseconds actually spent rendering this commit. |
baseDuration | Estimated milliseconds to re-render the whole subtree with no memoization at all. |
startTime/commitTime | Timestamps for when React began and finished the update. |
actualDuration far below baseDuration means memoization is working; the two being equal on every update means nothing is being skipped.
What trips people up
Calling setState from onRender schedules another commit, which fires onRender again — an easy infinite loop. Guard the write, or buffer into a ref.
Profiling is stripped from the production build by default, so a wrapper left in shipping code measures nothing unless you build the special profiling bundle.
Example
When to use it
- Confirm that adding memo() actually lowered actualDuration on a hot list.
- Send commit timings for a checkout flow to an analytics endpoint in a profiling build.
- Catch renders whose phase is "nested-update", which means state was set during a commit.
- Compare actualDuration against baseDuration to see how much memoization is saving.
Frequently asked questions
Why are all my timings zero in production?
Profiling is disabled in the standard production build, so the callback either never fires or reports nothing useful. Measuring a production bundle requires the special profiling build, which keeps the instrumentation and its overhead.
What does the "nested-update" phase mean?
It means the commit was scheduled from inside another commit — typically a setState in a layout Effect or in the render callback itself. Nested updates block the browser from painting between them, so seeing them repeatedly is worth investigating.
What is the difference between actualDuration and baseDuration?
actualDuration is the time this commit really cost, while baseDuration estimates what re-rendering the entire subtree from scratch would cost. The gap between them is how much your memoization saved on that commit.
More examples
Several trees, one collector
Both profilers share one callback and are told apart by their id. The collected log shows two "mount" entries on first paint, then an "update" entry for each id per click — the sidebar re-renders too, because its parent did, which is the signal that it is a candidate for memo().
import { Profiler, useState } from "react";
const log = [];
function record(id, phase, actualDuration, baseDuration, startTime, commitTime) {
log.push({ id, phase, actualDuration, baseDuration, startTime, commitTime });
}
function Sidebar({ items }) {
return (
<ul>
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
}
function Content({ body }) {
return <p>{body}</p>;
}
export default function Page() {
const [n, setN] = useState(0);
const items = ["inbox", "drafts", "sent"];
return (
<div>
<button onClick={() => setN((v) => v + 1)}>Update content</button>
<Profiler id="Sidebar" onRender={record}>
<Sidebar items={items} />
</Profiler>
<Profiler id="Content" onRender={record}>
<Content body={`Revision ${n}`} />
</Profiler>
</div>
);
}Proving memo() paid off
On mount the two durations are close, because everything really is rendered. On each subsequent click actualDuration drops to a fraction of baseDuration: the items array is unchanged, so the memoized list bails out and React only pays for the button. If both numbers stayed equal, the memo would not be taking effect.
import { Profiler, memo, useState } from "react";
const rows = Array.from({ length: 500 }, (_, i) => `row ${i}`);
function Row({ label }) {
return <li>{label}</li>;
}
const MemoList = memo(function List({ items }) {
return (
<ul>
{items.map((label) => (
<Row key={label} label={label} />
))}
</ul>
);
});
export default function Comparison() {
const [tick, setTick] = useState(0);
function onRender(id, phase, actualDuration, baseDuration) {
// actualDuration collapses toward 0 on updates once the list bails out,
// while baseDuration stays at the full cost of rendering all 500 rows.
console.log(id, phase, { actualDuration, baseDuration });
}
return (
<div>
<button onClick={() => setTick((t) => t + 1)}>Tick {tick}</button>
<Profiler id="List" onRender={onRender}>
<MemoList items={rows} />
</Profiler>
</div>
);
}Buffering timings without re-entering the tree
Typing pushes one entry per keystroke into a ref, which does not schedule work, and a timer outside the profiled tree flushes the batch into state every two seconds. This is the pattern for shipping timings to an analytics endpoint: the measurement itself never becomes a source of the renders it is measuring.
import { Profiler, useEffect, useRef, useState } from "react";
export default function BufferedTimings() {
const buffer = useRef([]);
const [flushed, setFlushed] = useState([]);
const [value, setValue] = useState("");
function onRender(id, phase, actualDuration) {
// Writing to a ref never schedules a render, so no loop is possible.
buffer.current.push(`${id}:${phase}`);
}
useEffect(() => {
const timer = setInterval(() => {
if (buffer.current.length === 0) return;
const batch = buffer.current;
buffer.current = [];
setFlushed((prev) => [...prev, batch.join(", ")]);
}, 2000);
return () => clearInterval(timer);
}, []);
return (
<div>
<Profiler id="Input" onRender={onRender}>
<input value={value} onChange={(e) => setValue(e.target.value)} />
</Profiler>
<ul>
{flushed.map((line, i) => (
<li key={i}>{line}</li>
))}
</ul>
</div>
);
}
Discussion