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 as IntersectionObserver.callback, ResizeObserver.callback, DOMWindow.onclick or 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.

Anatomy of a Long Animation Frame EntryFour stacked layers. The frame entry covers the whole frame duration and blocking duration. Its scripts array lists each script over five milliseconds. Each script timing names the invoker, such as an IntersectionObserver callback, and the invoker type. Each script also carries its source URL, function name and forced style and layout duration.FramestartTime, duration, renderStart, blockingDurationscripts[]Every script over 5 ms that ran in the frameinvoker / invokerTypee.g. IntersectionObserver.callback, user-callbackSource and costsourceURL, sourceFunctionName, duration, forced layout

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

TypeScript
// 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

TypeScript
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.

Observer Time in Long Frames, by Script OriginA bar chart of total time spent in observer callbacks within long animation frames on an article page, grouped by script origin. The ad SDK's IntersectionObserver callback leads at about four hundred and twenty milliseconds per session, most of it forced layout. The analytics tag follows at about ninety. The site's own reveal code and the lazy-loading library are each under forty.Median session, article pages, observer callbacks in LoAF entriesad SDK: IO callback~420 ms (310 forced)analytics tag: IO callback~90 mssite reveal: IO callback~35 mslazy-load lib: IO callback~20 ms

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.

What LoAF Shows and What It MissesTwo columns. Long Animation Frame entries show slow frames over fifty milliseconds with per-script attribution, including third-party observer callbacks and forced layout time, with no instrumentation needed. They miss callbacks that are moderately expensive in every frame without pushing any single frame past fifty milliseconds, which User Timing around your own callbacks can catch.LoAF showsFrames over 50 ms, with rendering includedPer-script invoker, origin and functionThird-party observer callbacks, no instrumentationForced layout time per scriptLoAF missesCallbacks that cost 10 ms every frame but never 50Browsers without the entry typeWork outside the main thread

Verification Steps

  • Check PerformanceObserver.supportedEntryTypes includes long-animation-frame in 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.


↑ Back to PerformanceObserver & Rendering Metrics