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.

Production signals and their blind spotsUse long animation frames for attribution and rAF sampling only inside short windows.Production signals and their blind spotsSeesMissesOverheadrAF intervalsamplingMain-thread frame gapsCompositor-only smoothnessOne callback per frameLong animationframesLate frames and their scriptsFrames late by under 50 msNegligibleEvent Timing /INPInteraction to first paintMid-animation stutterNegligible
Use long animation frames for attribution and rAF sampling only inside short windows.

Step-by-step resolution

Instrumenting one interactionShort windows, sampled sessions, segmented reporting.Instrumenting one interaction1Pick an interaction such as opening the navigation drawer.A measurable window2Start rAF sampling when the interaction begins and stop when the animation ends.Bounded overhead3Count intervals longer than 1.5 times the expected frame time.An approximate dropped-frame count4Collect long animation frame entries in the same window.Attribution for late frames5Send a compact summary on pagehide for a sampled fraction of sessions.Field data without noise6Segment by device memory, CPU cores and connection type.
Short windows, sampled sessions, segmented reporting.

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 requestAnimationFrame callback 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.

Share of drawer-open interactions with at least one late frameField data segmented by device class; the lab machine resembles the top row.Share of drawer-open interactions with at least one late frameDesktop, 8+ cores2 %Recent phone, 8 cores6 %Mid-range phone, 8 cores, 4 GB21 %Low-end phone, 4 cores, 2 GB47 %
Field data segmented by device class; the lab machine resembles the top row.

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.