Diagnosing Dropped Frames with PerformanceObserver

Your visualization feels mostly smooth but stutters at moments you can never reproduce on demand — and an open DevTools recording changes the timing enough that the jank vanishes.

The reliable approach is to instrument the page with PerformanceObserver so the browser reports slow frames as they happen, in the field, without a profiler attached. This guide sits under frame-rate stabilization techniques, within the high-performance animation and GPU acceleration overview.

Diagnostic checklist

What each entry type tells you

A long animation frame against the 16.67 ms budget A frame whose script, style, and paint phases exceed the budget is reported as a long animation frame. One frame · budget 16.67 ms 16.67 ms line → budget script (long task) style/layout paint total 48 ms → 2 frames dropped
A long-animation-frame entry attributes the overrun to script, style, and paint phases so you know which to fix.
Entry type Reports Best for
long-animation-frame (LoAF) Frames > 50 ms with script/style/paint attribution and the offending script URL Pinpointing what in a frame ran long
longtask Any main-thread task ≥ 50 ms Cheap, broad “something blocked the thread” signal
event Per-interaction processing duration (with durationThreshold) Measuring input latency that drives INP

The three entry types form a hierarchy of specificity. A longtask entry tells you that a task ran for 50 ms or more, but nothing about which frame it ruined or what code ran. It is the cheapest, most widely supported signal, and it is a good first net. A long-animation-frame entry is dramatically more useful: it is scoped to a single rendered frame and includes a scripts array attributing the time to specific script invocations, each with a sourceURL, an invoker (the event or callback that triggered it), and its own duration. It also breaks the frame down into rendering work versus scripting work via fields like renderStart and styleAndLayoutStart, so you can tell whether your jank is JavaScript or layout. An event entry is orthogonal: it measures the latency of a single user interaction from input to the next paint, which is the raw material for Interaction to Next Paint (INP), the responsiveness metric that replaced First Input Delay.

The reason to instrument all three rather than just opening DevTools is the observer effect. A profiler attached to the page changes the timing — it adds overhead, and on a fast development machine the jank that bites real users on a mid-range phone often will not reproduce at all. PerformanceObserver runs in production, on real devices, with negligible overhead, and reports the slow frames that actually happened to actual users. That is the only way to catch jank you cannot see by hand.

Why intermittent jank hides

Intermittent jank is hard precisely because it is correlated with conditions you do not control in the lab: a garbage collection pause that happens to land during an animation, a layout invalidation triggered by a third-party widget, a data update that coincides with a scroll. Each of these produces a single long frame among thousands of fast ones. Averages hide it completely — a 60 fps average can contain dozens of dropped frames that the user feels as a stutter. You have to look at the tail of the frame-duration distribution, and the way to capture that tail in the field is to record every frame that exceeds the budget and ship those records back for aggregation.

Broken vs fixed

// ❌ BROKEN: trying to "measure" frames by counting rAF callbacks.
// This only tells you frames happened, not which ones were slow or why.
let frames = 0;
function loop(): void {
  frames++; // no duration, no attribution, no field signal
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
// ✅ FIXED: observe long animation frames with attribution.
// Reports real overruns in the field, names the script, and survives prod.
const obs = new PerformanceObserver((list) => {
  for (const entry of list.getEntries() as PerformanceLongAnimationFrameTiming[]) {
    // PERF: blockingDuration is time the frame stayed busy past the budget
    if (entry.duration > 50) {
      const worst = entry.scripts.sort((a, b) => b.duration - a.duration)[0];
      reportJank({
        frameMs: entry.duration,
        blockingMs: entry.blockingDuration,
        culprit: worst?.sourceURL ?? 'unknown',
      });
    }
  }
});
// A11Y: jank disproportionately harms users on low-end assistive setups;
// field data surfaces issues a fast dev machine hides.
obs.observe({ type: 'long-animation-frame', buffered: true });

Step-by-step fix

interface JankReport {
  frameMs: number;
  blockingMs: number;
  culprit: string;
}

const BUDGET = 1000 / 60; // 16.67 ms

function droppedFrames(durationMs: number): number {
  return Math.max(0, Math.floor(durationMs / BUDGET) - 1);
}

function installJankMonitor(report: (r: JankReport) => void): () => void {
  const observers: PerformanceObserver[] = [];

  const supports = (type: string): boolean =>
    PerformanceObserver.supportedEntryTypes?.includes(type) ?? false;

  if (supports('long-animation-frame')) {            // Step 1
    const loaf = new PerformanceObserver((list) => {
      for (const e of list.getEntries() as PerformanceLongAnimationFrameTiming[]) {
        const worst = [...e.scripts].sort((a, b) => b.duration - a.duration)[0]; // Step 2
        report({ frameMs: e.duration, blockingMs: e.blockingDuration, culprit: worst?.sourceURL ?? 'n/a' });
      }
    });
    loaf.observe({ type: 'long-animation-frame', buffered: true });
    observers.push(loaf);
  } else if (supports('longtask')) {                 // Step 3 fallback
    const lt = new PerformanceObserver((list) => {
      for (const e of list.getEntries()) {
        report({ frameMs: e.duration, blockingMs: e.duration - BUDGET, culprit: 'longtask' });
      }
    });
    lt.observe({ type: 'longtask', buffered: true });
    observers.push(lt);
  }

  if (supports('event')) {                           // Step 4: INP signal
    const ev = new PerformanceObserver((list) => {
      for (const e of list.getEntries() as PerformanceEventTiming[]) {
        // PERF: processing time blocks the next paint, hurting INP
        report({ frameMs: e.duration, blockingMs: e.processingEnd - e.processingStart, culprit: e.name });
      }
    });
    ev.observe({ type: 'event', durationThreshold: 40, buffered: true });
    observers.push(ev);
  }

  return () => observers.forEach((o) => o.disconnect());
}

Verification

// Force a synthetic 80 ms block and assert the monitor catches it.
installJankMonitor((r) => {
  console.assert(r.frameMs >= 50, 'should report a long frame');
  console.assert(droppedFrames(r.frameMs) >= 1, 'should count dropped frames');
});
const start = performance.now();
while (performance.now() - start < 80) { /* block the thread */ }

Trigger the block and confirm a report arrives with frameMs near 80 and droppedFrames of at least 3. In the DevTools Performance panel, a matching purple/yellow long task should appear at the same timestamp, corroborating the field signal.

Turning raw entries into a dropped-frame rate

A single long frame is a data point; what you usually want is a rate you can track over time and alert on. Accumulate the total overrun across a session and divide by the frame budget to estimate frames lost, then express it as a fraction of frames that should have rendered. For example, a 60-second session at 60 Hz should produce 3,600 frames; if your accumulated droppedFrames across all long-animation-frame entries sums to 90, roughly 2.5% of frames were dropped. Tracking that percentage per route or per interaction type tells you where to spend optimization effort. Pair the dropped-frame rate with the INP value from event entries: a page can have a healthy frame rate during steady-state animation but still post a poor INP because a single interaction blocked for 300 ms. The two metrics catch different failures, and a serious visualization needs both in its field telemetry.

When you have identified a culprit sourceURL, the fix usually lands in one of three buckets: the script is doing too much synchronous work (chunk it, move it to a worker, or memoize it), it is triggering layout (batch reads before writes — see the layout-thrashing guide linked below), or it is allocating heavily and provoking garbage collection (pool objects and reuse typed arrays). The observer tells you where; the remediation techniques live in the parent guide.

Edge cases and gotchas

  • LoAF is not universal. Feature-detect with PerformanceObserver.supportedEntryTypes and fall back to longtask; never assume the entry type exists.
  • Observer callbacks run on the main thread and can themselves cause jank if they do heavy work — batch reports and ship them with navigator.sendBeacon during idle time.
  • INP needs the full interaction, not just the handler. The event entry’s duration spans input-to-next-paint, so a fast handler can still post a slow INP if rendering is the bottleneck — the processingStart to processingEnd window tells you the handler cost, while the gap from processingEnd to the entry’s end reveals rendering delay. Cross-check both with frame-rate stabilization techniques.
  • Buffered entries can flood the first callback. With buffered: true the observer delivers entries that occurred before it was registered, which on a slow-loading page can be a large batch all at once. Process them in bulk but defer any reporting network call to idle time so the diagnostic itself does not cause a long task.