The cost of a re-render, measured
“Too many re-renders” is the most repeated and least measured claim in frontend. We reach for memo, useMemo, and useCallback on reflex, rarely checking whether the render we’re avoiding cost anything in the first place. So I took a real dashboard — a few thousand DOM nodes, live data, the works — and measured.
What a render actually does
A React render is two phases. The render phase runs your components and diffs the result; it’s pure JavaScript and usually cheap. The commit phase applies changes to the DOM; it’s where the browser does layout and paint, and it’s usually where the time goes.
The mistake is optimizing the first phase to avoid the second — when the second never ran.
// This re-renders on every keystroke...
function Row({ item }: { item: Item }) {
return <td>{item.label}</td>;
}
// ...but if `label` didn't change, the commit phase is a no-op.
// React still bailed out of touching the DOM. The render was free-ish.
The measurement
I instrumented the dashboard with the Profiler API and recorded actualDuration for every commit during a typical session: filtering, sorting, and a live feed updating once a second.
84% of commits took under 1ms. The expensive 16% were all the same shape: a parent re-rendering a large list where every child genuinely changed.
That reframed the whole problem. memo on the leaf rows did nothing measurable — the rows were changing. What helped was not rendering 2,000 rows at all.
What actually moved the needle
- Virtualize long lists. Rendering only the visible window cut the worst commits by 90%. This was the single biggest win and it has nothing to do with memoization.
- Hoist expensive derived data. One
useMemoaround a sort+group of 40k rows mattered. A hundreduseMemos around string concatenations did not. - Split state by update frequency. The once-a-second feed was re-rendering the filter UI for no reason. Moving it into its own subtree isolated the churn.
// Isolate high-frequency state so it can't re-render its quiet siblings.
function LiveFeed() {
const tick = useTick(); // updates every second
return <Ticker value={tick} />;
}
// <Filters /> lives outside LiveFeed and never sees `tick`.
The folklore that didn’t help
Wrapping every callback in useCallback added code, added allocations for the dependency arrays, and changed no measurement. Same for memoizing components whose props changed every render anyway — you pay for the comparison and still re-render.
The takeaway
Profile before you memoize. The expensive renders are rarely the ones we instinctively reach to prevent; they’re the big commits where the DOM genuinely changed, and the fix is usually “do less work,” not “skip work that was already cheap.” Measure the commit, not your anxiety.