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.
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
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
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.
Related
- Diagnosing main-thread jank in the Performance panel — what to do once the measurement says something is wrong
- Firefox and Gecko scroll-driven animation support — the engine where this measurement replaces a missing panel
- Core Web Vitals for Scroll & View Transitions — the field metrics this complements
- Animation Performance: Engine Support Matrix — the cross-engine routine this measurement serves