skip to content

React's `<Profiler>` passes both `actualDuration` and `baseDuration` to its `onRender` callback. What does each one measure, and what do you conclude when the two stay nearly equal on every update?

level: middleimportance: should knowfreq 45%

answer

  1. one is measured, the other estimated
  2. what ran versus what could have run
  3. gap between them is the signal
  4. mount phase makes them equal by definition
  5. no gap on updates means nothing bailed out

basics

~20 s

actualDuration is the time React really spent rendering the profiled subtree for this update; baseDuration is the estimated time to render that whole subtree from scratch with nothing skipped. When the two stay nearly equal on updates, almost nothing is being skipped.

solid answer

~50 s

`actualDuration` is the milliseconds React actually spent rendering the Profiler's subtree for that commit. `baseDuration` is an estimate of what rendering the entire subtree would cost with no bail-outs at all — React derives it from the most recent render time of each component in the subtree, so it is a worst-case reference, not a measurement of this update. On the `mount` phase the two are naturally close, because nothing can be skipped on a first render. On updates the interesting quantity is the gap: a much smaller `actualDuration` means React bailed out of most of the subtree, while `actualDuration` sitting at roughly `baseDuration` update after update means nearly every component re-rendered. That is a signal to look at why — new object or function props, a context value recreated each render, or a parent cascade — not proof that you should reach for memoization.

code

jsx · 17 lines
jsx
import { Profiler } from 'react';

function handleRender(id, phase, actualDuration, baseDuration, startTime, commitTime) {
  if (phase === 'mount') return;
  const skipped = 1 - actualDuration / baseDuration;
  console.log(`${id} ${phase}: ran ${actualDuration.toFixed(1)}ms of a possible ` +
    `${baseDuration.toFixed(1)}ms (skipped ${(skipped * 100).toFixed(0)}%), ` +
    `update took ${(commitTime - startTime).toFixed(1)}ms end to end`);
}

export function Measured({ children }) {
  return (
    <Profiler id="ItemList" onRender={handleRender}>
      {children}
    </Profiler>
  );
}

go deeper

for a junior

Learn the plain distinction: one number is what React actually spent this time, the other is what a full no-shortcuts render of the same subtree would cost.

for a middle

Explain that baseDuration is estimated from each component's most recent render time, that mount makes the two equal by construction, and that on updates it is the ratio — not either number alone — that carries the signal.

for a senior

Demonstrate judgment about what the numbers exclude: paint, layout and time before the render began. Say what you would investigate first when the gap is small, and why structural fixes usually beat sprinkling comparisons over every child.

for a principal

Decide what threshold, if any, is worth alerting on, and defend it. Argue why a falling baseDuration — genuinely less work — is a better outcome than a falling actualDuration bought with memoization everyone must maintain.

## The two numbers React's `Profiler` calls `onRender(id, phase, actualDuration, baseDuration, startTime, commitTime)` after each commit that rendered the profiled subtree. Two of those arguments are durations, and they answer different questions. **`actualDuration`** is a measurement. It is the number of milliseconds React spent rendering this Profiler and its descendants for **this specific update**. It counts only the work that actually ran. If React skipped a memoized child because its props were unchanged, that child's cost is not in the number. **`baseDuration`** is an estimate, and it deliberately ignores what was skipped. It approximates how long it would take to render the **entire** subtree with no memoization and no bail-outs — every component re-rendering. React computes it by summing the most recent individual render duration recorded for each component in the subtree. Because those per-component times come from whenever each component last rendered, `baseDuration` is a slowly-moving worst-case reference rather than a fresh measurement of this commit. Both numbers cover React's **render** work only: running your component functions and reconciling the result. They are not paint times, not layout, not the cost of the browser applying the resulting DOM changes. ## Reading them together The ratio is where the diagnostic value lives. - **On `phase === 'mount'`,** the two are close by construction. A first render has nothing to skip, so `actualDuration ≈ baseDuration`. Nothing is wrong; that is the definition of a mount. - **On `phase === 'update'`, a much smaller `actualDuration`** means React did far less than the full subtree — most of it bailed out. That is the healthy shape for a large subtree responding to a small state change. - **On updates, `actualDuration` staying at roughly `baseDuration`** means essentially everything re-rendered. For a cheap subtree this may be perfectly fine. For an expensive one it is the signal to investigate. ```jsx function handleRender(id, phase, actualDuration, baseDuration) { if (phase === 'update' && actualDuration > baseDuration * 0.8) { console.warn(`${id}: re-rendered nearly the whole subtree (${actualDuration.toFixed(1)}ms)`); } } ``` Note what the signal is and is not. "Everything re-rendered" is a fact about the update; it is not a verdict that the fix is to wrap things in memoization. Common causes worth ruling out first are structural: a parent re-rendering and passing freshly-created object, array or inline-function props down; a context value rebuilt on every parent render; state that lives higher in the tree than the data it drives. Fixing the cause often removes the cost entirely, whereas adding a comparison to every child adds work on the path where nothing changed. ## The other four arguments `id` is the string you gave the Profiler. `phase` is `'mount'`, `'update'`, or `'nested-update'`; the last one means the update was scheduled from inside a lifecycle or effect while a previous commit was being applied — the classic render-then-immediately-render-again pattern, which is worth noticing because it doubles the work for one interaction. `startTime` is when React began rendering this update and `commitTime` is when it committed. `commitTime` is shared by every Profiler callback that fires for the same commit, so it doubles as a grouping key when you have several boundaries. That also gives you a second, different measure of latency: `commitTime - startTime` covers the whole update including any interruption and other work between them, whereas `actualDuration` is only the render work attributed to this subtree. They answer different questions and can diverge substantially under concurrent rendering, where a render can be started, interrupted and resumed. ## Caveats before you trust a number Development builds are slower than production, so absolute millisecond values run high. StrictMode intentionally renders components twice in development, which inflates `actualDuration` further. And profiling is disabled in the standard production build, so numbers taken from a normal shipped bundle are not real measurements. The practical consequence: use these numbers **comparatively**. Record the pair before a change and after it, in the same build, on the same machine, with the same interaction. A drop in `actualDuration` at unchanged `baseDuration` means you got React to skip more work. A drop in `baseDuration` means the subtree itself became genuinely cheaper — you removed work rather than avoided it — and that is usually the better win.

  • On the very first commit, actualDuration and baseDuration are almost identical. Is that a problem?
    No — that is the mount phase, where nothing can be skipped because nothing has rendered before. Check `phase === 'mount'` before drawing conclusions. The equality only carries information on updates, where a bail-out was possible and did not happen.
  • Why can baseDuration be stale rather than a measurement of the current commit?
    Because React builds it from the last recorded render duration of each component in the subtree, and components that bailed out this commit contribute their previous timing. It is an estimate of the no-memoization worst case, assembled from measurements taken at different moments — so treat it as a slowly-moving reference line, not a reading of this update.
  • actualDuration is only 2 ms but the interaction still feels slow. What does that tell you?
    That the cost is not in this subtree's render work. actualDuration excludes browser layout and paint, work in other subtrees, and time spent waiting before the render started. Compare `commitTime - startTime` against actualDuration to see whether the update sat around, and look outside React for style recalculation and paint cost.
  • What does a phase value of 'nested-update' indicate?
    That the update was scheduled from inside a lifecycle or effect while React was already committing, so React had to render again immediately. It means one interaction produced two render passes. It is worth flagging in telemetry because it doubles the work and often points at state being synchronised in an effect that could have been derived during render.

saying these in an interview costs you the question

  • Thinks baseDuration is the time before optimization was added
  • Reads actualDuration as including browser paint and layout
  • Treats mount-phase equality of the two as a performance bug
  • Assumes a small gap always means you must add memoization
  • Compares development timings against a production budget

context