Debugging Jank with the Long Animation Frames API

Part of Main-Thread Scheduling & Long Animation Frames in Performance Budgeting & GPU Architecture.

The problem

Users report that a carousel “stutters sometimes” and that the cart drawer “feels slow on my phone”. Local traces on a development laptop show nothing: every frame fits the budget. The stutter is real, but it depends on a device, a network state, a logged-in session or a third-party script that the lab does not reproduce.

The Long Animation Frames API exists for exactly this gap. It runs in real users’ browsers, reports frames that were presented late, and says which script — by URL, function name and the event or callback that invoked it — was responsible.

Root cause analysis: late frames have three shapes

A frame is late because the main thread could not start or finish rendering on time. The API reports each late frame with enough timing to tell which of three shapes it has.

Blocked before rendering. One or more tasks ran between the previous frame and this one, and rendering could not start until they finished. The gap between startTime and renderStart is large, and scripts lists the tasks with their durations.

Forced layout inside script. A script wrote to the DOM and then read a layout value, forcing the browser to run style and layout synchronously. The script’s entry has a large forcedStyleAndLayoutDuration, and the classic culprit is a read of offsetWidth or getBoundingClientRect() in a loop.

Slow rendering. Scripts were short, but the rendering step itself — requestAnimationFrame callbacks, style recalculation, layout and paint — was expensive. The gap between renderStart and the end of the frame dominates, and styleAndLayoutStart shows how much of it was style and layout rather than rAF callbacks.

blockingDuration is the summary number for responsiveness: the portion of the frame’s long tasks beyond 50ms each, which is time an input event would have had to wait.

Reading an entry: scripting delay against rendering costThe same 140 ms frame calls for opposite fixes depending on where renderStart falls.Reading an entry: scripting delay against rendering costrenderStart latestartTime to renderStart: 118 msscripts[0]: 104 ms, click handlerRendering after that: 22 msSplit or defer the scriptrenderStart earlystartTime to renderStart: 9 msstyleAndLayoutStart to end: 121 msNo script over 5 msReduce style and layout work
The same 140 ms frame calls for opposite fixes depending on where renderStart falls.

Step-by-step resolution

From field entries to a shipped fixThe API tells you where to look; a local trace tells you what to change.From field entries to a shipped fix1Register the observer in the first script that runs, with buffered: true.Frames during page load are included2Keep the top few entries per page view, ranked by blockingDuration.Payload stays small3Record renderStart and styleAndLayoutStart offsets for each kept frame.Scripting and rendering delay are separable4Send the summary on pagehide with sendBeacon.Data survives navigation and tab close5Aggregate by sourceURL, function and invokerType, weighted by blocking time.The worst offender rises to the top6Reproduce that invoker locally under CPU throttling and fix it.
The API tells you where to look; a local trace tells you what to change.

Production code pattern

// Collect a compact long-animation-frame summary per page view.
const worst = [];

function keep(frame) {
  const scripts = [...frame.scripts]
    .sort((a, b) => b.duration - a.duration)
    .slice(0, 2)
    .map((s) => ({
      src: s.sourceURL,                            // file that ran
      fn: s.sourceFunctionName,                    // may be empty for anonymous functions
      invoker: s.invoker,                          // e.g. "IMG#hero.onload", "TimerHandler:setTimeout"
      type: s.invokerType,                         // event-listener, user-callback, resolve-promise, ...
      ms: Math.round(s.duration),
      forced: Math.round(s.forcedStyleAndLayoutDuration),
    }));

  worst.push({
    at: Math.round(frame.startTime),
    ms: Math.round(frame.duration),
    blocking: Math.round(frame.blockingDuration),
    beforeRender: Math.round(frame.renderStart - frame.startTime),
    styleLayout: Math.round(frame.startTime + frame.duration - frame.styleAndLayoutStart),
    scripts,
  });
  worst.sort((a, b) => b.blocking - a.blocking);
  worst.length = Math.min(worst.length, 5);        // keep the five worst
}

if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
  new PerformanceObserver((list) => list.getEntries().forEach(keep))
    .observe({ type: 'long-animation-frame', buffered: true });

  addEventListener('pagehide', () => {
    if (worst.length) {
      navigator.sendBeacon('/rum/loaf', JSON.stringify({
        page: location.pathname,
        reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
        frames: worst,
      }));
    }
  });
}
/* The drawer this data led back to: its entrance now starts before the handler's heavy work. */
.drawer { transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1); }
.drawer:not(.is-open) { transform: translateX(100%); }
@media (prefers-reduced-motion: reduce) {
  .drawer { transition: none; }
}

Rendering Impact: main-thread, negligible. Entries are delivered after presentation, and the summary is sent once per page view. Recording reducedMotion lets you check that a latency problem is not hidden by users who have animations disabled.

Two fields need care. sourceFunctionName is empty for anonymous arrow functions, so name the handlers you care about. And sourceURL for inline scripts is the page URL, which collapses unrelated inline code into one bucket; move significant inline code into files if you want useful attribution.

Blocking time attributed per 1,000 page views, before the fixAggregated by invoker. The drawer's click handler was forcing layout on every open.Blocking time attributed per 1,000 page views, before the fixBUTTON.cart-toggle.onclick41 sTimerHandler:setInterval (carousel)23 sThird-party tag, resolve-promise17 sIMG.onload (lazy images)6 sEverything else5 s
Aggregated by invoker. The drawer's click handler was forcing layout on every open.

Turning the top invoker into a fix

The aggregate names a handler; the local trace shows what that handler does. In the example above, the cart toggle’s click handler read the drawer’s height to position a shadow, after it had inserted the cart items — a forced layout of the whole drawer, attributed as forcedStyleAndLayoutDuration of around 70ms on mid-range phones. The fix was to apply the open class first, move the item rendering behind scheduler.yield(), and replace the measured shadow with a pseudo-element, following the pattern in yielding to the main thread during animations.

The carousel’s setInterval entry had the rendering shape instead: short script, long style and layout, because each tick changed left on a flex row of fifty slides. Moving the slide offset to transform removed it from the report entirely.

Verification checklist

Constraints and trade-offs

  • The API is currently Chromium-only; field data represents those users and should not be read as the whole audience.
  • Only scripts longer than 5ms within a frame are attributed, so many small tasks can make a frame late without any single script appearing.
  • Cross-origin scripts without CORS report limited detail; third-party tags may show only a source URL.
  • Buffered entries are capped, so a very janky page load can lose early frames if the observer is registered late.
  • Sending every entry is expensive and noisy; summarise on the client.

Frequently asked questions

How is blockingDuration different from duration?

duration is the whole time from the start of the work to presentation. blockingDuration sums the parts of each long task beyond 50ms, which is the time an input would have waited. A frame can have a long duration and zero blocking time if it was made of many short tasks.

Why does an entry have no scripts listed?

Either no single script exceeded the 5ms attribution threshold, or the time was spent in rendering rather than scripting. Check the gap between renderStart and the end of the frame; a large one means style, layout or paint was slow.

Can I use the API to measure CSS animation smoothness?

Indirectly. A compositor-driven CSS animation can stay smooth during a long animation frame, so a late frame does not prove visible jank. It does show when animations that depend on the main thread — script-driven ones, layout-property transitions and animation starts — were delayed.

Does collecting this data slow the page down?

Not meaningfully. Entries are queued by the browser and delivered asynchronously, and a client-side summary sent once on pagehide adds a trivial amount of work.