Measuring Dropped Frames in Production
Part of Frame Budgeting & 16ms Targets in Performance Budgeting & GPU Architecture.
The problem
A team has optimised a drawer animation until it is smooth on every device in the office. Support tickets still mention “laggy” navigation. Lab traces cannot reproduce it. Without data from real sessions, the next step is guesswork: more optimisation, a different device to test on, or a decision that the users are wrong.
Production measurement answers a narrow, useful question: during this specific interaction, how often did frames arrive late, and what was running when they did?
Root cause analysis: what the platform can tell you
There is no API that reports “dropped frames” directly for the page. Three signals together get close.
requestAnimationFrame timestamps. A callback that re-schedules itself receives a timestamp each frame. The gaps between timestamps approximate the interval between rendering opportunities. If the display refreshes every 16.7ms and gaps of 50ms appear, frames were missed. It cannot see frames the compositor presented without the main thread being involved, and it adds a per-frame callback, so it should run only inside measured windows.
Long animation frames. Entries report rendering updates delayed beyond 50ms, with script attribution. They cost nothing when nothing is late and are the best signal for why frames were late, as covered in debugging jank with the Long Animation Frames API.
Event Timing and INP. These measure the latency from an interaction to the next paint, which captures the delay before an animation’s first frame — the number users describe as “laggy” more often than mid-animation stutter, as described in measuring INP for animated interactions.
What none of them see. Compositor-only animations that keep running while the main thread is blocked look perfect to a rAF sampler that is itself blocked. Conversely, a smooth page can show long animation frames from unrelated background work. Interpret the three together.
Step-by-step resolution
Production code pattern
const SAMPLE_RATE = 0.05; // instrument 5% of sessions
const sampled = Math.random() < SAMPLE_RATE;
const report = [];
function measureFrames(label, signalDone) {
if (!sampled) return signalDone;
const frames = [];
let raf = requestAnimationFrame(function tick(t) {
frames.push(t);
raf = requestAnimationFrame(tick);
});
const loafs = [];
const observer = PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')
? new PerformanceObserver((list) => loafs.push(...list.getEntries().map((f) => ({
ms: Math.round(f.duration),
script: f.scripts?.[0]?.sourceURL,
fn: f.scripts?.[0]?.sourceFunctionName,
}))))
: null;
observer?.observe({ type: 'long-animation-frame' });
return () => {
cancelAnimationFrame(raf);
observer?.disconnect();
// No API reports the display's refresh rate; estimate it from the fastest gaps seen.
const gaps = frames.slice(1).map((t, i) => t - frames[i]).sort((a, b) => a - b);
const expected = gaps.length ? Math.max(8, gaps[Math.floor(gaps.length * 0.1)]) : 16.7;
let late = 0;
for (let i = 1; i < frames.length; i++) {
if (frames[i] - frames[i - 1] > expected * 1.5) late++;
}
report.push({ label, frames: frames.length, late, loafs: loafs.slice(0, 3) });
signalDone?.();
};
}
// Usage around a known animation.
drawerButton.addEventListener('click', () => {
const done = measureFrames('drawer-open');
openDrawer();
drawer.addEventListener('transitionend', () => done(), { once: true });
});
addEventListener('pagehide', () => {
if (!report.length) return;
navigator.sendBeacon('/rum/animation', JSON.stringify({
report,
deviceMemory: navigator.deviceMemory,
cores: navigator.hardwareConcurrency,
connection: navigator.connection?.effectiveType,
reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
}));
});
/* The animation being measured stays compositor-only. */
.drawer { transition: translate var(--motion-enter-duration) var(--motion-enter-easing); }
@media (prefers-reduced-motion: reduce) { .drawer { transition: none; } }
Rendering Impact: one
requestAnimationFramecallback per frame during measured windows only, on a sampled fraction of sessions. Outside those windows the instrumentation is a passive observer with negligible cost.
Recording reducedMotion alongside the frame data matters: sessions with reduced motion have no animation to measure, and mixing them into the same average hides regressions. Device memory and core count usually explain more variance than anything else in the payload.
Turning data into action
Field data is only useful if it points at a change. Group late-frame reports by the attributed script and function; a single handler usually dominates. Compare segments: if only low-memory devices are affected, the cause is more likely texture memory or eviction than script, which points at the analysis in texture memory budget for mobile. If the same interaction is late across all devices, it is structural — a layout animation, or work started in the same task as the animation. And if INP is high while mid-animation frames are fine, the problem is the delay before the animation, not the animation itself.
Verification checklist
Constraints and trade-offs
- rAF sampling measures main-thread rendering opportunities, not presented frames, and is blind while the main thread is blocked.
- Long animation frames are Chromium-only.
- No API exposes the display refresh rate; estimating it from the fastest observed gaps is approximate and needs enough frames to be stable.
- Instrumentation itself costs a callback per frame; keep windows short.
- Field data is noisy; look at distributions and shares, not individual sessions.
Frequently asked questions
Can I measure dropped frames in real users’ browsers?
Not directly. Sampling requestAnimationFrame intervals during a known interaction approximates it, and long animation frame entries report late rendering updates with script attribution.
Does measuring frames slow the page down?
A per-frame callback has a cost, so run it only during short measured windows and on a sampled fraction of sessions.
Why do my field numbers look worse than lab traces?
Real devices are slower and busier than development machines. Segment by device memory and core count before comparing.
What if INP is poor but frames during the animation are fine?
The problem is the delay before the animation starts, usually a long task in the interaction handler, rather than the animation itself.
Related
- Frame Budgeting & 16ms Targets — the budget being verified
- Enforcing Animation Performance Budgets in CI — the lab counterpart
- Testing Animations on Low-End Devices — reproducing what the data shows