Attributing Jank with the Long Animation Frames API
Field data says your Interaction to Next Paint is poor, but nothing is obviously slow on your laptop, and the old Long Tasks API only tells you that the main thread was blocked, not by what. This guide from Performance Panel Flame Graph Analysis, within Browser DevTools & Performance Profiling Workflows, shows how Long Animation Frames (LoAF) entries attribute slow frames to specific scripts and functions in real users’ browsers, and how to turn that attribution into a DevTools reproduction.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Poor INP in field data, fine in the lab | Real devices, data volumes and third-party scripts differ | Collect long-animation-frame entries with script attribution |
Names the function behind the slowest frames |
| Long Tasks entries have no useful attribution | longtask reports a container, not a script |
Switch the observer to long-animation-frame |
Each entry lists scripts with URL and function name |
| Slow frames blamed on your bundle but code looks cheap | Forced style and layout inside your script | Read forcedStyleAndLayoutDuration per script |
Separates script cost from layout thrashing |
| Third-party tags suspected but unproven | Tag scripts run in the same frames | Group LoAF scripts by sourceURL origin |
Quantifies share of blocking time per vendor |
| Frames slow only after long sessions | GC pauses grow with retained heap | Correlate LoAF duration with session length and heap | Distinguishes leaks from constant script cost |
Root Cause: Long Tasks Measure Blocking, LoAF Measures Frames
The older Long Tasks API reports any task over 50 ms, but it describes the task, not the work that happened inside it, and it ignores the rendering work that follows. A frame can be slow because of one long task, several medium tasks back to back, a heavy requestAnimationFrame callback, or style and layout after the script finishes — and Long Tasks sees only part of that.
The Long Animation Frames API, available in Chromium-based browsers since version 123, reports any animation frame whose total work exceeds 50 ms. Each PerformanceLongAnimationFrameTiming entry includes the frame’s duration, its blockingDuration, timestamps for when rendering started (renderStart) and when style and layout started (styleAndLayoutStart), and — crucially — a scripts array. Each script entry describes a script that ran for more than 5 ms during the frame: its invoker (for example BUTTON#save.onclick or Response.json.then), invokerType (event listener, user callback, promise resolution, classic script), sourceURL, sourceFunctionName, sourceCharPosition, its duration, and forcedStyleAndLayoutDuration, the time it spent in forced reflows.
That is enough attribution to act on from field data alone: you learn which function, in which file, triggered by which event, made the frame long. Because entries are produced in users’ browsers, they reflect the real device class, real data sizes and the real mix of third-party tags. They also capture memory-driven jank indirectly. A page that leaks will see garbage-collection pauses lengthen as the heap grows, and those pauses land inside the frames; plotting LoAF duration against session length or against heap size (via field memory measurement) reveals that pattern even when no single script looks expensive.
Step-by-Step Fix
- Observe LoAF entries in production. Register a
PerformanceObserverforlong-animation-framewithbuffered: trueearly in page load, as in the code below. Verification: in Chrome’s Console,performance.getEntriesByType('long-animation-frame')returns entries after you interact with a heavy page. - Summarise each frame before sending. For each entry, keep the duration, blocking duration, and the top two or three scripts by duration with their
invoker,sourceURL,sourceFunctionNameandforcedStyleAndLayoutDuration. Verification: the beacon payload is under a few KB per session. - Aggregate by function across users. In your analytics store, group script entries by
sourceURL+sourceFunctionNameand rank by total blocking time contributed. Verification: you have a top-ten list with the share of long-frame time each function causes. - Split script cost from forced layout. For each top function, compare
durationwithforcedStyleAndLayoutDuration. Verification: you know whether to optimise the JavaScript itself or fix read/write ordering. - Reproduce in DevTools with matching conditions. Record the same interaction in DevTools → Performance with CPU throttling set to resemble your users’ devices and realistic data volume. Verification: the flame chart shows the same function inside a long task, with Layout slices if LoAF reported forced layout.
- Fix, deploy, and watch the field metric. Ship the fix and compare the function’s aggregated blocking time and your INP percentile over the next few days. Verification: the function drops out of the top-ten list and p75 INP improves.
Command and Code Reference
Use case: collect compact LoAF attribution and send it with the page-hide beacon. Keep only what you need to rank functions; full entries are large.
// loaf-collector.js — load early (inline or first script)
const frames = [];
if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
new PerformanceObserver((list) => {
for (const f of list.getEntries()) {
frames.push({
dur: Math.round(f.duration),
blocking: Math.round(f.blockingDuration),
// keep the three heaviest scripts; each script entry is >5 ms by definition
scripts: [...f.scripts]
.sort((a, b) => b.duration - a.duration)
.slice(0, 3)
.map((s) => ({
invoker: s.invoker, // e.g. "BUTTON#save.onclick"
type: s.invokerType, // "event-listener", "resolve-promise"…
url: s.sourceURL.split('?')[0], // strip query strings
fn: s.sourceFunctionName || '(anonymous)',
dur: Math.round(s.duration),
forcedLayout: Math.round(s.forcedStyleAndLayoutDuration),
})),
});
if (frames.length > 50) frames.shift(); // bound memory on long sessions
}
}).observe({ type: 'long-animation-frame', buffered: true });
}
// Send once when the page is hidden (covers tab close and navigation)
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && frames.length) {
navigator.sendBeacon('/rum/loaf', JSON.stringify(frames.splice(0)));
}
});
Use case: rank functions from collected beacons. A simple server-side aggregation gives the top-ten list the workflow relies on.
// aggregate-loaf.mjs — reads newline-delimited JSON beacons from stdin
import readline from 'node:readline';
const totals = new Map();
for await (const line of readline.createInterface({ input: process.stdin })) {
for (const frame of JSON.parse(line)) {
for (const s of frame.scripts) {
const key = `${s.fn} · ${s.url}`;
const t = totals.get(key) || { ms: 0, layoutMs: 0, n: 0 };
t.ms += s.dur;
t.layoutMs += s.forcedLayout; // high ratio → fix read/write ordering
t.n += 1;
totals.set(key, t);
}
}
}
console.table([...totals].sort((a, b) => b[1].ms - a[1].ms).slice(0, 10)
.map(([k, v]) => ({ fn: k, totalMs: v.ms, forcedLayoutMs: v.layoutMs, frames: v.n })));
Verification and Regression Prevention
The fix is confirmed in the field, not only in the lab: the targeted function’s aggregated blocking time should fall by the amount you removed, and its rank should drop out of the top of the list within a few days of deployment. INP at the 75th percentile is the headline number to watch; improvements in a single function often move it only a little, so also track the count of long animation frames per session, which responds faster.
Keep the collector running permanently and alert on changes in the ranking: a new function entering the top five after a release is a regression signal with attribution attached. Because the collector itself retains data, keep it bounded as in the example — an unbounded array of performance entries is a small memory leak of its own in long-lived single-page apps. For lab-side regression tests of specific interactions, combine this with spotting garbage collection pauses in a performance trace so frames lengthened by GC are caught before release.
Edge Cases and Gotchas
Cross-origin scripts hide their details
Scripts loaded from another origin without CORS report limited attribution: the sourceURL may be present but function names and character positions can be withheld. Serve third-party scripts with crossorigin="anonymous" and an Access-Control-Allow-Origin header where the vendor supports it, or accept attribution at the URL level for those rows.
Frames that contain no long script
Some long animation frames list no scripts at all, or only short ones, because the time went to rendering: a huge style recalculation after a class change on <body>, or layout of a very large DOM. Compare renderStart and styleAndLayoutStart with the frame’s start time; a large gap after styleAndLayoutStart points to rendering cost, which is fixed with CSS containment and smaller DOMs rather than JavaScript changes.
Background tabs and bfcache restores
Frames in hidden tabs are throttled and can report misleading durations, and a page restored from the back/forward cache produces a burst of work that is not an interaction. Filter entries recorded while document.visibilityState was hidden, and tag entries that occur shortly after a pageshow event with persisted === true so they can be analysed separately.
Observer overhead
The observer itself is cheap, but serialising large entries on every frame is not. Summarise inside the callback, cap stored entries as the collector example does, and send data only on page hide. That keeps the measurement from contributing to the jank it measures.
Frequently Asked Questions
How is LoAF different from the Long Tasks API?
Long Tasks reports individual tasks over 50 ms with little attribution. LoAF reports whole animation frames over 50 ms, including rendering work, and attributes them to the scripts that ran — with URL, function name, invoker and forced-layout time. It explains slow frames caused by several medium tasks, which Long Tasks misses entirely.
Does LoAF work in Safari and Firefox?
At the time of writing it is available in Chromium-based browsers. Feature-detect with PerformanceObserver.supportedEntryTypes as in the example, and treat the Chromium data as representative for attribution; the functions that are slow on Chrome are usually slow elsewhere too.
Why is the function name empty for some scripts?
Anonymous functions, minified code and some framework-generated callbacks have no name. Use sourceURL and sourceCharPosition together with your source maps to map the position back to the original function, and name important handlers explicitly so attribution is readable.
Related
- Performance Panel Flame Graph Analysis — the parent topic
- Finding Forced Reflow and Layout Thrashing — fixing the forced-layout share LoAF reports
- Sampling Memory Telemetry Without Hurting Performance — correlating slow frames with heap growth in the field
- Browser DevTools & Performance Profiling Workflows — the section overview