Observe long-animation-frame entries with a PerformanceObserver; each entry covers one frame over 50 ms and lists the scripts that ran in it — with invoker (such as an observer callback), sourceURL, sourceFunctionName, duration and forcedStyleAndLayoutDuration — which is enough to attribute slow frames to specific observer callbacks from real users.
Problem / Scenario Context
A news site's field data shows scroll jank and poor INP on article pages, but the Long Tasks API only says "a 180 ms task happened" with no hint of what ran. The pages load a dozen third-party scripts, three of which use observers — an ad viewability SDK, an analytics tag and a lazy-loading library — plus the site's own reveal animations. Which of them is responsible?
Long Animation Frames (LoAF) replaced Long Tasks for exactly this attribution problem. The PerformanceObserver & Rendering Metrics topic covers the observer; this page applies LoAF to observer callbacks, complementing the task-level approach in correlating long tasks with observer callbacks.
Mechanics Explanation
A long animation frame is measured from the start of the first task after the previous frame's rendering to the end of the next rendering update, when that span exceeds 50 ms. It includes all tasks in between and the rendering work — style, layout, ResizeObserver callbacks, paint — which Long Tasks never covered.
Each PerformanceLongAnimationFrameTiming entry has frame-level fields (startTime, duration, renderStart, styleAndLayoutStart, blockingDuration) and a scripts array of PerformanceScriptTiming objects for scripts that took more than 5 ms. For each script:
invoker— how it was called, such asIntersectionObserver.callback,ResizeObserver.callback,DOMWindow.onclickor a URL for top-level script execution. The exact strings vary somewhat by browser version.invokerType— category:classic-script,module-script,event-listener,user-callback,resolve-promise,reject-promise.sourceURL,sourceFunctionName,sourceCharPosition— where the invoked function lives.duration,forcedStyleAndLayoutDuration— total time, and how much of it was forced layout.
Observer callbacks appear with invokerType: 'user-callback' and an invoker naming the observer type, which makes them easy to filter.
Comparison Table: Long Tasks vs Long Animation Frames
| Capability | Long Tasks (longtask) |
Long Animation Frames (long-animation-frame) |
|---|---|---|
| Unit | one task over 50 ms | one frame over 50 ms |
| Includes rendering work | no | yes — style, layout, RO callbacks, paint |
| Script attribution | container only | per script: invoker, URL, function |
| Forced layout time | no | yes, per script |
| Catches many short tasks in one frame | no | yes |
| Availability | Chromium | Chromium |
Minimal Reproducible Example
// Long Tasks: tells you something was slow, not what.
new PerformanceObserver((list) => {
for (const t of list.getEntries()) console.log('long task', t.duration, (t as any).attribution?.[0]?.containerSrc);
}).observe({ type: 'longtask', buffered: true });
On the news page this prints durations and, at best, an iframe URL — never "the ad SDK's intersection callback".
Production-Safe Solution
interface ScriptTiming {
invoker: string; invokerType: string; sourceURL: string; sourceFunctionName: string;
duration: number; forcedStyleAndLayoutDuration: number;
}
interface LoAF extends PerformanceEntry { blockingDuration: number; renderStart: number; scripts: ScriptTiming[] }
interface ObserverCost { invoker: string; source: string; fn: string; total: number; forced: number; frames: number }
const costs = new Map<string, ObserverCost>();
const OBSERVER_INVOKER = /(Intersection|Resize|Mutation|Performance)Observer/;
function originOf(url: string): string {
try { return new URL(url).origin; } catch { return url || '(inline)'; }
}
if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
new PerformanceObserver((list) => {
for (const frame of list.getEntries() as LoAF[]) {
for (const s of frame.scripts) {
if (!OBSERVER_INVOKER.test(s.invoker)) continue;
const key = `${s.invoker}|${originOf(s.sourceURL)}|${s.sourceFunctionName}`;
const c = costs.get(key) ?? { invoker: s.invoker, source: originOf(s.sourceURL),
fn: s.sourceFunctionName || '(anonymous)', total: 0, forced: 0, frames: 0 };
c.total += s.duration;
c.forced += s.forcedStyleAndLayoutDuration;
c.frames += 1;
costs.set(key, c);
}
}
}).observe({ type: 'long-animation-frame', buffered: true });
}
addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden' || costs.size === 0) return;
const top = [...costs.values()].sort((a, b) => b.total - a.total).slice(0, 5);
navigator.sendBeacon('/rum/observer-loaf', JSON.stringify({ page: location.pathname, top }));
costs.clear();
});
Grouping by invoker, script origin and function name turns thousands of frames into a short ranked list per page. Reporting the origin rather than the full URL keeps payloads small and groups versioned bundles together. The forced column separates callbacks that are slow themselves from callbacks that trigger expensive synchronous layout — the second group is usually cheaper to fix.
On the news site, the ranking made the answer obvious within a day: the ad SDK's IntersectionObserver callback accounted for most observer-attributed long-frame time, with a large forced-layout share from reading getBoundingClientRect on every ad slot per callback.
Using LoAF in the Lab Too
The same entries are available in a DevTools session, which makes LoAF a quick triage tool before recording a full trace. Paste the observer into the console with buffered: true, scroll the page, and print the summary with console.table([...costs.values()]). Callbacks with large forced values are prime candidates for the read-then-write restructuring in spotting forced reflow inside ResizeObserver callbacks; callbacks with large total but small forced need their work split or deferred.
Because a frame is only reported when it exceeds 50 ms, LoAF will not show callbacks that are consistently moderate — 10 ms every frame during a scroll — which still cost smoothness. User Timing measures around your own callbacks fill that gap for code you control.
Verification Steps
- Check
PerformanceObserver.supportedEntryTypesincludeslong-animation-framein your target browsers. - Scroll an observer-heavy page in the lab and confirm your summary names the callbacks you expect.
- Compare with a Performance trace of the same scroll to validate the attribution.
- Watch the ranking over time in field data after each release.
- Confirm payload sizes stay small by reporting only the top few per page.
Common Mistakes to Avoid
- Sending every frame entry to the server. Aggregate in the page first.
- Matching invoker strings exactly. They vary by version; use a pattern.
- Ignoring
forcedStyleAndLayoutDuration. It often explains most of a callback's cost. - Assuming LoAF catches all jank. Moderate per-frame costs stay below its threshold.
FAQ
How is a long animation frame different from a long task?
A long task is one task over 50 ms. A long animation frame spans all tasks and the rendering work between two frames, so it catches many short tasks that add up, and work done during rendering — including ResizeObserver callbacks — that long tasks never measured.
Which browsers support long-animation-frame?
Chromium-based browsers. Check PerformanceObserver.supportedEntryTypes before observing, and skip gracefully elsewhere.
Why is sourceFunctionName sometimes empty?
Anonymous functions, and some minified bundles, provide no name. The sourceURL and sourceCharPosition still locate the code, and naming your own callbacks makes them easy to recognise.
Are cross-origin scripts attributed?
Yes, with their URLs, which is what makes LoAF valuable for identifying third-party observers. Details inside cross-origin iframes are not exposed to the parent page.
What does blockingDuration mean on a frame entry?
It is the total time within the frame during which the main thread could not respond to input promptly — roughly the sum of the portions of long tasks beyond 50 ms, plus rendering time when it pushed the frame over the threshold. It is the best single number for how much a frame hurt responsiveness.
Can I see which element an observer callback was handling?
No. Script timing identifies the function and its source, not its arguments. To connect a slow callback to specific elements, add your own User Timing measure with a detail payload inside the callback.
How much overhead does observing long animation frames add?
Very little: entries are only created for frames that are already slow, and the attribution data is collected by the browser as part of its normal bookkeeping. Aggregating in the page and sending one summary per visit keeps network cost negligible.
Should I use LoAF to compute INP?
No. INP comes from Event Timing entries. LoAF explains why an interaction was slow by showing what ran in the frames around it.
Related
- Measuring INP with PerformanceObserver Event Timing — the metric LoAF explains
- Keeping Observer Callbacks Under the INP Budget — acting on the findings
- Measuring Callback Cost with User Timing Marks — covering what LoAF misses
↑ Back to PerformanceObserver & Rendering Metrics