<Profiler> puts React 7,897 's own timers in your hands. Wrap part of the tree, give it an id and an onRender callback, and React calls back after every commit inside that subtree with six arguments: id, phase ("mount", "update" or "nested-update"), actualDuration, baseDuration, startTime and commitTime. The durations are the interesting pair — actualDuration is what this commit cost, baseDuration what it would have cost with no memoization.
<div id="root"></div><pre id="out" style="font:13px Consolas"></pre>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">{ "imports": { "react": "https://esm.sh/react@19.3.0?dev",
"react-dom": "https://esm.sh/react-dom@19.3.0?dev",
"react-dom/client": "https://esm.sh/react-dom@19.3.0/client?dev" } }</script>
<script type="text/babel" data-type="module">
import React, { Profiler, memo } from 'react';
import { createRoot } from 'react-dom/client'; import { flushSync } from 'react-dom';
const onRender = (id, phase, actual, base, start, commit) => { out.textContent +=
`${id} ${phase.padEnd(6)} actual ${actual.toFixed(1)} ms ` +
`base ${base.toFixed(1)} ms commit at ${commit.toFixed(0)} ms\n`; };
const Row = memo(function Row({ n }) {
let s = 0; for (let k = 0; k < 3e4; k++) s += Math.sqrt(k + n);
return <li>row {n}: {s.toFixed(0)}</li>;
});
const Grid = ({ q }) => <ul style={{ height: 20, overflow: 'hidden', margin: 0 }}>
{Array.from({ length: 300 }, (_, i) => <Row key={i} n={i % 2 ? i : i + q} />)}</ul>;
const root = createRoot(document.getElementById('root'));
const draw = (q) => flushSync(() => root.render(
<Profiler id="grid" onRender={onRender}><Grid q={q} /></Profiler>));
draw(0); draw(1);
</script>
Half the rows are memoized against unchanged props, and the second commit shows it: actualDuration lands well under baseDuration, the signal that memoization is earning its keep. When the two are equal nothing is being skipped; when actualDuration stays high across updates that should be cheap, you have found your subtree.
The demo loads React's development build on purpose. <Profiler> needs the extra timers, so its callback never fires in a plain production build — for numbers from deployed code use the profiling build (react-dom/profiling, 699,916 bytes against 625,168 for the production client), whose timers add overhead of their own.