Long Animation Frames (LoAF): Attributing Slow Frames to the Script That Caused Them

This topic extends the interactivity tooling in Core Web Vitals & Measurement with the one browser API that tells you which script made a frame slow, not merely that it was slow.

For years the only field signal for main-thread congestion was the Long Tasks API: an entry saying "something ran for 180ms" with an attribution field that, in practice, almost always read unknown. That was enough to prove a problem existed and useless for assigning it to a team. The Long Animation Frames API (LoAF), stable in Chromium since version 123, replaces the task as the unit of measurement with the frame — the full span from the start of work to the next paint — and attaches a list of the scripts that ran inside it, with their source URL, function name, character position, invoker type and how long each spent in forced style and layout.

That shift matters because Interaction to Next Paint is itself a frame-based measurement. An interaction fails the 200ms budget when the frame that should present its result is late, and the reason a frame is late is rarely one task: it is a sequence of event listeners, promise continuations, a framework commit and a rendering phase that together overran. LoAF reports exactly that sequence, so the gap between "INP is 340ms at p75" and "the onChange handler in filters.js spends 190ms in forced layout" closes from days to minutes.

One long animation frame, as LoAF reports it A 240 millisecond frame broken into three script entries, a style and layout phase and paint, with the 50 millisecond long-frame threshold marked. One long animation frame, as LoAF reports it Scripts click listener promise then commit Rendering style + layout paint 0ms 50ms 100ms 150ms 200ms 250ms 50ms long-frame line LoAF lists every script entry with its source URL and function, then reports renderStart and styleAndLayoutStart for the tail of the frame.

The Metric Degradation LoAF Explains

The failure mode this topic addresses is an INP p75 above 200ms where the Performance panel shows a busy main thread but nothing obviously wrong in your own code. Typical shapes:

  • Input delay dominates (>100ms) because an unrelated script — a tag manager, a polling widget, a hydration chunk — owns the thread when the user taps. The Long Tasks API would report an unattributed task; LoAF names the script and the invoker that started it.
  • Processing time dominates because several listeners fire for one interaction (a pointerdown, a click and a framework's synthetic dispatcher) and each looks cheap alone. LoAF sums them inside one frame.
  • Presentation delay dominates because the handler finished quickly but invalidated a large layout. LoAF's renderStart and styleAndLayoutStart timestamps isolate the rendering tail, and each script's forcedStyleAndLayoutDuration shows when layout was forced inside JavaScript instead.

A frame is considered long once it exceeds 50ms — the same budget the long-task definition uses — and a long frame overlapping an interaction is what pushes it past the 200ms boundary. The blockingDuration field approximates how much of that frame was unavailable for input, which is the figure that correlates with input delay in the field.

Prerequisites

  • Chromium 123+ for the long-animation-frame entry type. Firefox and Safari do not expose it, so treat LoAF data as a Chromium-only diagnostic sample; it remains representative because the scripts are the same across engines.
  • The web-vitals library v4 or later, attribution build. Its INP attribution includes longAnimationFrameEntries for the interaction's frame, which saves you correlating timestamps by hand.
  • Same-origin or CORS-enabled scripts. A cross-origin script loaded without crossorigin and a matching Access-Control-Allow-Origin header reports its sourceURL but an empty function name and position — enough to blame a vendor, not to find the line.
  • DevTools with the Performance panel's "Show custom tracks" option if you want to plot LoAF entries alongside the flame chart during lab reproduction.

1. Environment Setup: Observe Long Frames Without Distorting Them

Register a single PerformanceObserver with buffered: true as early as possible so frames that happened before your observer existed are still delivered. Keep the callback cheap — it runs on the main thread you are trying to measure.

javascript
// loaf-observer.js — load early, before the app bundle if you can.
const frames = [];
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration < 50) continue;            // only long frames
    frames.push(entry);
    if (frames.length > 200) frames.shift();     // bounded memory on long sessions
  }
}).observe({ type: 'long-animation-frame', buffered: true });
// trade-off: buffered:true replays a limited buffer of past entries, not the
// whole session. If you register late (after hydration), the slowest load-time
// frames may already have been evicted — inline the observer in <head> when
// load-phase attribution matters.

2. Capture a Baseline: Which Frames Overlap Interactions?

Not every long frame matters. A 300ms frame during a background data refresh with no user input is a throughput problem, not an INP problem. The baseline you want is the set of long frames that contain an interaction: LoAF exposes firstUIEventTimestamp, which is non-zero when the frame processed user input.

javascript
const interactive = frames.filter((f) => f.firstUIEventTimestamp > 0);
console.table(interactive.map((f) => ({
  start: Math.round(f.startTime),
  duration: Math.round(f.duration),
  blocking: Math.round(f.blockingDuration),
  scripts: f.scripts.length,
  renderTail: Math.round(f.startTime + f.duration - f.renderStart),
})));
// trade-off: firstUIEventTimestamp marks frames that *handled* input, but a
// long frame immediately BEFORE the input is what inflates input delay. Keep
// the preceding frame too when you are chasing input delay rather than processing.

Record this on a throttled lab profile (4x CPU) for the interactions your RUM flags as slowest. Two or three frames will dominate; that is your shortlist.

3. Isolate the Bottleneck: Read the Script Entries

Each entry's scripts array is the heart of the API. For each script you get invoker (for example BUTTON#save.onclick or Response.json.then), invokerType (event-listener, user-callback, resolve-promise, classic-script, module-script), sourceURL, sourceFunctionName, sourceCharPosition, duration, and forcedStyleAndLayoutDuration.

Reading a LoAF script entry Which LoAF script fields answer which debugging question, with the fields that point to a fix highlighted. Reading a LoAF script entry Field Question it answers What to do with it invokerType Who started this work? listener, promise, timer or script eval invoker Which element or API? jump straight to the handler sourceURL + position Which file and line? open in Sources panel forcedStyleAndLayout Did JS force layout? batch reads before writes pauseDuration Was it blocked on sync XHR or alert? remove the sync API

Sort the scripts in the slowest interactive frames by duration and look at the top three. In practice they fall into a small number of recognisable patterns: one heavy listener (fix with yielding), a third-party script evaluating during the interaction (fix with deferral), or a cheap listener with a large forcedStyleAndLayoutDuration (fix the read/write order). The guide on finding slow scripts with LoAF script attribution walks through each pattern on a real trace.

4. Apply the Fix Matched to the Phase

LoAF does not fix anything; it routes you to the right existing technique:

Routing a long frame to its fix A decision sequence that maps the dominant part of a long animation frame to the optimisation technique that addresses it. Routing a long frame to its fix One script entry over 50ms? Split it with scheduler.yield() or move it to a worker yes no Third-party sourceURL in the frame? Defer that vendor until idle or interaction yes no forcedStyleAndLayout is large? Batch DOM reads before writes yes no Render tail dominates — shrink the DOM update

Deconstructing a Long Animation Frame

A LoAF entry has enough timestamps to split its duration into four phases, and each has a practical budget if the whole frame is to stay under roughly 100ms so the interaction lands under 200ms once input delay is added.

PhaseDerived asPractical budgetTypical culprit
Script executionsum of scripts[].duration< 50mslisteners, framework commit
Forced layout inside scriptsum of forcedStyleAndLayoutDuration< 10msread-after-write in a loop
Pre-render workrenderStart − (last script end)< 5msmicrotask chains, requestAnimationFrame callbacks
Style, layout, paintstartTime + duration − renderStart< 30mslarge DOM mutation, expensive selectors

Phase budget vs a failing frame Bars comparing each phase of a failing 238 millisecond frame against a 50 millisecond reference line. Phase budget vs a failing frame Script execution 128ms Forced layout in script 41ms Pre-render callbacks 9ms Style + layout + paint 60ms 50ms reference

The blockingDuration field is distinct from all of these: it is the sum, across the long tasks inside the frame, of time beyond the first 50ms of each, plus rendering time. Use it as a ranking key for input-delay risk, and the phase split above to decide what to change.

Advanced Diagnostics and Edge Cases

Cross-origin scripts with empty attribution. When sourceFunctionName is blank and sourceCharPosition is -1, the script was loaded without CORS. Adding crossorigin="anonymous" fixes attribution only if the vendor serves Access-Control-Allow-Origin; if they do not, the request will fail outright, so test before shipping.

Frames with zero scripts. A long frame with an empty scripts array means the cost was entirely rendering — commonly a CSS animation on a layout property or a content-visibility region being rendered for the first time. Look at the render tail, not your JavaScript.

Framework dispatchers hiding the real handler. React and Vue attach a single root listener, so invoker reads something like DIV#root.onclick for every interaction. Use sourceFunctionName and sourceCharPosition with source maps to resolve the real component; or set performance.mark() calls in suspect handlers and match timestamps.

Script entries under 5ms are omitted. The specification only lists scripts above a small duration threshold, so a frame built from hundreds of tiny callbacks may report a long duration with few scripts. That shape — death by a thousand microtasks — usually indicates a reactive store notifying too many subscribers.

Extension noise. Browser extensions inject content scripts that appear in lab LoAF data with chrome-extension:// URLs. Filter them out of RUM aggregation, but do not ignore them in support tickets: a slow extension is a real user's real INP.

Validation and Budgeting

Lock the fix in at two levels. In the lab, assert that no interactive frame in a scripted interaction exceeds a budget, using Puppeteer to drive the interaction and read entries from the page:

javascript
// ci/assert-loaf.mjs — fail the build if the scripted interaction produces a long frame.
const worst = await page.evaluate(() => new Promise((resolve) => {
  const seen = [];
  new PerformanceObserver((l) => seen.push(...l.getEntries()))
    .observe({ type: 'long-animation-frame', buffered: true });
  document.querySelector('#apply-filters').click();
  setTimeout(() => resolve(Math.max(0, ...seen
    .filter((f) => f.firstUIEventTimestamp > 0).map((f) => f.duration))), 1000);
}));
if (worst > 100) throw new Error(`interactive frame ${Math.round(worst)}ms > 100ms budget`);
// trade-off: CI hardware is faster than a mid-tier phone, so a 100ms lab budget
// roughly corresponds to a 200ms+ field frame. Calibrate the threshold against
// your own RUM rather than copying this number.

In the field, ship the top script entries for the INP interaction in your RUM beacon — the guide on sending LoAF data in RUM beacons covers the payload budget — and alert when the share of INP frames attributed to a single sourceURL jumps release-over-release.

Closing the loop from field frame to fixed handler Five-stage loop from RUM capture of a long frame through grouping, lab reproduction and fix to a CI budget assertion. Closing the loop from field frame to fixed handler RUM beacon top 3 scripts for INP Group by sourceURL + function Reproduce lab trace at 4x CPU Fix yield CI budget interactive frame cap

Code Example: Joining LoAF to the INP Interaction

The web-vitals attribution build already does the correlation for you; this example shows the minimal useful extraction.

javascript
import { onINP } from 'web-vitals/attribution';

onINP(({ value, attribution }) => {
  const scripts = (attribution.longAnimationFrameEntries || [])
    .flatMap((f) => f.scripts)
    .sort((a, b) => b.duration - a.duration)
    .slice(0, 3)
    .map((s) => ({
      src: s.sourceURL.split('?')[0],
      fn: s.sourceFunctionName || '(anonymous)',
      type: s.invokerType,
      ms: Math.round(s.duration),
      layout: Math.round(s.forcedStyleAndLayoutDuration),
    }));
  navigator.sendBeacon('/rum', JSON.stringify({ inp: Math.round(value), scripts }));
});
// trade-off: stripping query strings keeps cardinality manageable but merges
// distinct vendor endpoints that differ only by parameters. Keep the full URL
// for first-party scripts if your bundles are fingerprinted by query string.

Patterns That Recur in Production LoAF Data

Once LoAF attribution has been flowing for a week, the same handful of shapes account for most failing interactions. Recognising them by their signature saves a reproduction cycle.

The tag-manager pile-up. The frame's slowest script has a sourceURL on a tag-manager host and an invokerType of classic-script or user-callback, and firstUIEventTimestamp falls inside it. The user tapped while a container was evaluating a dozen tags queued by a consent change or a page-view trigger. The fix is upstream: fire those tags from an idle callback rather than synchronously on the trigger, or move them server-side. Nothing in your own handler will help.

The hydration collision. On the first interaction after load, the slowest script is a framework chunk with an invoker such as Window.requestIdleCallback or a module script evaluation, and input delay is large while processing is small. The interaction arrived mid-hydration. You either hydrate less (selective hydration with React Suspense and islands are the main tools) or make hydration yield so the input can slip in between chunks.

The reactive fan-out. Many scripts of a few milliseconds each, all from the same store or signals library, with a total duration above 100ms. One state change notified hundreds of subscribers. Narrow the subscription (selectors, shallowRef, memoised derived state) rather than speeding up any one subscriber.

The layout ping-pong. A modest script duration with a forcedStyleAndLayoutDuration making up most of it — say 70ms of an 85ms listener. Some loop reads offsetHeight or getBoundingClientRect() after writing a class. The fix is mechanical: read everything first, then write, or move the measurement into a ResizeObserver.

The silent renderer. Script entries total under 30ms but the frame is 180ms because the render tail is huge. The DOM change was legitimately small in code and enormous in effect — toggling a class on <body> that every rule in a 400KB stylesheet depends on, or inserting 2,000 table rows. Containment and virtualisation, not JavaScript, move this number.

Five recurring long-frame signatures A comparison of five common long animation frame shapes, the field that identifies each, and the fix it points to. Five recurring long-frame signatures Signature Tell-tale field Fix direction Tag-manager pile-up vendor sourceURL at input time Defer tags to idle Hydration collision framework chunk, high input delay Hydrate less or in chunks Reactive fan-out many tiny scripts, one library Narrow subscriptions Layout ping-pong forced layout most of duration Read then write Silent renderer short scripts, long render tail Contain or virtualise

FAQ

Does LoAF replace the Event Timing API for INP?

No. Event Timing still defines which interaction is the INP candidate and how long it took end to end. LoAF explains why the frame containing that interaction was slow. You need both: Event Timing to choose the interaction worth investigating, LoAF to attribute its cost. The web-vitals attribution build joins the two for you by matching the interaction's timestamps against the frames that overlap it.

Is the overhead of observing every long frame acceptable in production?

Observing is cheap — the browser records the entries whether or not you listen — but what you do in the callback is not free. Filtering to frames above 50ms, keeping a bounded buffer and doing the serialisation only when you actually send a beacon keeps the cost to a fraction of a millisecond per frame. The expensive mistake is stringifying every entry eagerly or posting each one as it arrives.

Why does my LoAF total not match the INP value?

They measure different spans. INP starts at the hardware input timestamp and ends at the next paint; a LoAF entry starts when the frame's first task begins and ends after rendering. If the input arrived during a frame that started earlier, LoAF duration can exceed INP; if the interaction spanned two frames (a listener that scheduled a second frame via requestAnimationFrame), INP can exceed any single entry. Compare phases, not totals.

Guides in This Topic