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.

Anatomy of a long animation frame A 140 millisecond frame is drawn as a bar split into script one, an input event handler of 40 milliseconds, script two, a promise resolution of 70 milliseconds that includes 25 milliseconds of forced style and layout, and a rendering phase of 30 milliseconds starting at renderStart. Labels under each part name the LoAF fields that describe it. One long animation frame (duration 140 ms) script 1 · 40 ms BUTTON#save.onclick script 2 · 45 ms JS Response.json.then forced layout 25 ms render · 30 ms style, layout, paint invoker, invokerType sourceFunctionName script 2 duration = 70 ms incl. layout forcedStyleAndLayoutDuration = 25 ms renderStart, styleAndLayoutStart Actionable conclusion from field data alone: the fetch handler in app.js renders rows and reads offsetHeight per row — fix the read/write order, then shrink the 40 ms click handler

Step-by-Step Fix

  1. Observe LoAF entries in production. Register a PerformanceObserver for long-animation-frame with buffered: true early 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.
  2. 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, sourceFunctionName and forcedStyleAndLayoutDuration. Verification: the beacon payload is under a few KB per session.
  3. Aggregate by function across users. In your analytics store, group script entries by sourceURL + sourceFunctionName and rank by total blocking time contributed. Verification: you have a top-ten list with the share of long-frame time each function causes.
  4. Split script cost from forced layout. For each top function, compare duration with forcedStyleAndLayoutDuration. Verification: you know whether to optimise the JavaScript itself or fix read/write ordering.
  5. 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.
  6. 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.
Blocking time share by attributed function Aggregated across a week of sessions, renderRows in app.js accounts for 38 percent of long-frame blocking time, a tag manager script for 21 percent, onSaveClick for 14 percent, a chat widget for 9 percent, and all other scripts for 18 percent. Share of long-frame blocking time (one week, all sessions) renderRows · app.js 38% tag manager · third party 21% onSaveClick · app.js 14% chat widget · third party 9% everything else 18% first-party rows are fixable directly; third-party rows need loading strategy changes

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.

Field signals after a jank fix ships In the days after deployment, the fixed function’s aggregated blocking time falls first and it drops down the ranking. Long animation frames per session respond quickly. INP at the 75th percentile moves more slowly and by a smaller amount. relative days after deploy fixed function: blocking time long animation frames per session INP p75 (moves slowly)

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.