Measuring Frame Rate Across Engines

Each engine’s developer tools present frame timing differently, and none of them agree on the units, the visualisation or the thresholds. That makes cross-engine comparison from panels alone unreliable. A small measurement written into the page produces the same number everywhere, which is what turns “it feels worse in Safari” into a figure that can be acted on. This guide covers the measurement, the statistics worth reporting, and the mistakes that make the numbers meaningless. It sits under the engine support matrix.

When to use this approach

  • Comparing the same section across engines. This is the primary use, and the only reliable way to do it without three different sets of tooling assumptions.
  • Establishing a regression baseline. A single number recorded per release detects drift that no manual profiling schedule will.
  • Testing on a device you cannot attach a profiler to. A number rendered into the page works on any device with a browser.
  • Confirming a fix. Before and after figures from an identical run are far more persuasive than two traces.

Use a profiler instead when you need to know why — frame timing tells you that something is wrong, and nothing about where.

Three measurements, three blind spots Average frame time hides every spike and is close to useless. Worst frame time catches a single stall but says nothing about how often. The count of frames over the budget catches sustained roughness. Reporting all three from one recording costs nothing and removes each measure\u2019s blind spot. Average frame time hides every spike — a page with one 400 ms stall can still average 16 ms Worst frame time catches the single stall, says nothing about how often it happens Frames over budget, as a count catches sustained roughness that no single frame would flag Report all three from one recording; each is cheap and each covers a different failure.

Implementation

1. Record frame intervals, not frame rate

const frames = [];
let last = performance.now();

function tick(now) {
  frames.push(now - last);
  last = now;
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

Intervals are the raw data; every statistic worth reporting is derived from them. Averaging into a frame rate first throws away exactly the spikes you are looking for.

2. Derive the budget from the display

The budget is not always 16.7 ms Three display refresh rates and the frame budget each implies. At 60 Hz the budget is 16.7 milliseconds; at 90 Hz it is 11.1; at 120 Hz it is 8.3. A threshold hard-coded to 60 Hz reports a page as healthy on a high-refresh display while it is visibly dropping frames. frame budget by refresh rate 60 Hz 16.7 ms 90 Hz 11.1 ms 120 Hz 8.3 ms Derive the budget from the observed median frame interval rather than assuming 60 Hz, and the same threshold becomes meaningful on every display it runs on.
function summarise(intervals) {
  const sorted = [...intervals].sort((a, b) => a - b);
  const median = sorted[Math.floor(sorted.length / 2)];
  const budget = median * 1.5;           // 50% over the observed cadence
  return {
    median: median.toFixed(1),
    worst: Math.max(...intervals).toFixed(1),
    over: intervals.filter((d) => d > budget).length,
  };
}

Hard-coding 16.7 milliseconds reports a page as healthy on a 120 Hz display while it is visibly dropping every other frame. The median interval is a reliable proxy for the display’s actual cadence.

3. Measure the scroll window only

Measuring the right window A scripted scroll has three phases: settling after load, the scroll itself, and the tail after it stops. Measuring the whole recording includes load work that has nothing to do with the animation. Measuring only the scroll window is what makes the number comparable between runs and between engines. load and settle the scroll — measure here tail parsing, decoding, hydration momentum Including the load phase makes the measurement dominated by page weight rather than by the animation, which is why whole-recording numbers rarely reproduce between runs.
let capturing = false;
addEventListener('scroll', () => { capturing = true; }, { once: true, passive: true });

In an automated run, start capture immediately before the scripted scroll and stop immediately after it. Including page load makes the figure dominated by parsing and decoding, which is a real measurement of something else.

4. Report all three numbers

A single number hides too much. The median tells you the cadence, the worst frame tells you whether there was a stall, and the count over budget tells you whether the section was sustainedly rough. A page can be bad in any one of those ways while looking fine in the other two.

5. Run it the same way every time

// scripted scroll: consistent distance, consistent duration
async function scrollThrough(el, ms = 2000) {
  const start = performance.now();
  const distance = el.scrollHeight - innerHeight;
  return new Promise((resolve) => {
    (function step(now) {
      const t = Math.min(1, (now - start) / ms);
      scrollTo(0, distance * t);
      t < 1 ? requestAnimationFrame(step) : resolve();
    })(start);
  });
}

A hand-scrolled measurement varies by more than most regressions do. Scripted scrolling is what makes the numbers comparable.

Verification

Run the measurement twice in the same engine on the same machine and compare. If the two runs differ by more than about ten percent, the harness is too noisy to detect anything and the usual cause is including load work or scrolling by hand.

Then verify the budget derivation is working: log the median interval and confirm it matches the display you are on. A 120 Hz laptop reporting a 16.7 millisecond median means the page is already not keeping up, which is itself the finding.

Finally, deliberately break something — animate top instead of transform on one element — and confirm the measurement notices. A harness that reports the same number for a good and a bad implementation is measuring the wrong window.

Edge cases and gotchas

requestAnimationFrame stops in background tabs. An automated run that loses focus produces a huge apparent frame interval. Keep the tab foregrounded, or discard intervals above a sanity threshold.

Variable refresh rate displays. A display that adapts its rate produces a median that moves during the run. Take the median over the scroll window rather than over the whole session, and treat very large medians as a signal to check the display mode.

The measurement itself costs a frame callback. It is negligible relative to what it measures, but it is not free — do not leave it running in production.

Momentum tails. Scrolling stops but frames continue while momentum decays. Including the tail adds cheap frames that flatter the median; excluding it entirely can hide a stall that happens during the settle. Measuring to the point where scrolling has fully stopped is the consistent choice.

Comparing across devices rather than across engines. The number is only meaningful against another number from the same hardware. Cross-device comparison tells you about the devices.

Browser-specific notes

requestAnimationFrame and performance.now() behave identically in all three engines, which is precisely why the measurement is portable. The differences are in what the surrounding environment does to it.

Chromium throttles background frames aggressively and offers CPU throttling in devtools, which is useful for making a fast machine behave like a slower one — and which must be matched across engines or the comparison is meaningless.

Safari on iOS reduces the refresh rate in low power mode, which shows up as a larger median interval. That is a genuine finding rather than a measurement artefact, and it is worth capturing the power state alongside the numbers.

Firefox’s performance.now() resolution is reduced when resistFingerprinting is enabled, which some privacy configurations set. If the intervals arrive quantised to suspiciously round values, that is the cause.

Frequently Asked Questions

Is worst-frame time the right headline number?

It is the right one for detecting a stall, which is the failure users notice most. For a section that feels generally rough rather than occasionally broken, the count of frames over budget is the better headline. Reporting both avoids having to choose in advance which failure you are looking for.

Should this run in production?

No. It costs a frame callback per frame and produces data you have no way to attribute. If you want field data on animation smoothness, the long-animation-frame timing API and the standard vitals reporting cover it with far less overhead and much better attribution.

How does this relate to Interaction to Next Paint?

They measure different things. This measures the smoothness of an ongoing animation; INP measures how long a discrete interaction takes to produce a visible response. A page can have excellent INP and terrible scroll smoothness, and the reverse — which is why the vitals guide treats them separately.

What number is good enough?

A median close to the display cadence, a worst frame under about twice the budget, and a count over budget in the low single digits for a multi-second scroll. Beyond that the honest answer is comparative: the number you want is the one your previous release produced, or better.

Can the same harness measure a view transition?

Yes, with one adjustment: start capture immediately before calling startViewTransition and stop when the transition’s finished promise resolves. What you get is the smoothness of the transition’s animation, which is a different question from how long the callback blocked — the latter is better measured with a performance mark inside the callback itself.

Both numbers are worth having, and they fail independently. A transition can animate beautifully after a long frozen window, which reads to the user as a slow page followed by a nice effect.

How many samples make a result trustworthy?

Three runs of the same scripted scroll, taking the median of the three worst-frame figures, is enough to be confident that a difference of more than about twenty percent is real. Single runs are noisy enough that they routinely produce differences of that size from nothing, which is how measurement exercises end up disbelieved.


Up: Animation Performance: Engine Support Matrix