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
| 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.supportedEntryTypesand fall back tolongtask; 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.sendBeaconduring idle time. - INP needs the full interaction, not just the handler. The
evententry’sdurationspans input-to-next-paint, so a fast handler can still post a slow INP if rendering is the bottleneck — theprocessingStarttoprocessingEndwindow tells you the handler cost, while the gap fromprocessingEndto the entry’s end reveals rendering delay. Cross-check both with frame-rate stabilization techniques. - Buffered entries can flood the first callback. With
buffered: truethe 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.
Related
- Frame-rate stabilization techniques — the parent guide on holding a steady frame rate.
- requestAnimationFrame vs GSAP for data transitions — keep animation drivers inside the budget you are measuring.
- Reducing layout thrashing in real-time charts — a common root cause of the long frames you will find.