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.
Step-by-step resolution
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
reducedMotionlets 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.
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.
Related
- Main-Thread Scheduling & Long Animation Frames — the parent topic on how tasks block rendering
- Measuring INP for Animated Interactions — the interaction metric these frames feed
- Profiling Scroll-Driven Animations in DevTools — the lab-side trace workflow