Tracking Long Animation Frames

The long-animation-frame entry type (LoAF) reports any rendering frame that took longer than 50ms to produce and, unlike a bare long task, attributes the cost to the specific scripts that ran inside it. It is the most precise field signal for diagnosing slow interactions and has largely superseded longtask for INP work. This builds on PerformanceObserver API Patterns, part of Rendering Performance Metrics and Tooling.

What a LoAF Entry Contains

A long task only tells you the main thread was blocked for some duration in some frame. A LoAF entry frames the same stall around the render loop and exposes the structure of where the time went: how long was spent in scripts versus rendering versus style-and-layout, when rendering started, and a scripts array attributing slices to individual call sites.

field meaning
duration total length of the long animation frame
blockingDuration ms the frame blocked input beyond the 50ms allowance
renderStart when style/layout/paint began within the frame
styleAndLayoutStart when forced or scheduled layout work began
scripts[] per-entry-point attribution: invoker, sourceURL, duration

blockingDuration is the field most directly tied to interaction latency. Where longtask made you subtract 50ms by hand to estimate blocking, LoAF computes the input-blocking portion for you, accounting for frames where multiple tasks stacked up before the browser could render.

The entry lays the whole frame out on a single timeline, so each field is really a boundary marker between phases. Reading them left to right shows exactly where the 16.6ms budget was blown:

Anatomy of a long animation frame A timeline bar splitting one frame into script time, render start, style and layout, and paint, with the LoAF fields marking each boundary. One long animation frame β€” duration 97ms scripts[] β€” 90ms event handler runs, input cannot paint style + layout 4ms paint 3ms frame start renderStart t=90ms styleAndLayoutStart blockingDuration β€” 47ms input-blocking

Minimal Reproduction

// A handler that mutates state, then does heavy synchronous work in the same frame
input.addEventListener('input', (e) => {
  state.query = e.target.value
  // ❌ Expensive filter runs inside the rendering frame, delaying paint of the result
  results = catalogue.filter((item) => deepMatch(item, state.query)) // ~90ms
  renderResults(results)
})

The interaction’s visual update β€” the filtered list β€” cannot paint until this 90ms block finishes, so the user sees a frozen field and the frame is recorded as a long animation frame.

Observing LoAF and Its Script Attribution

const obs = new PerformanceObserver((list) => {
  for (const frame of list.getEntries()) {
    if (frame.blockingDuration > 0) {
      report({
        duration: frame.duration,
        blocking: frame.blockingDuration,    // input-blocking ms in this frame
        scripts: frame.scripts.map((s) => ({
          src: s.sourceURL,                  // file that owned the slice
          fn: s.invoker,                     // e.g. 'input.onclick'
          ms: s.duration,
          forcedLayout: s.forcedStyleAndLayoutDuration, // sync layout cost
        })),
      })
    }
  }
})
obs.observe({ type: 'long-animation-frame', buffered: true }) // replay early frames

The scripts array is what makes LoAF actionable. A long task says β€œ118ms in window”; a LoAF says β€œ90ms in search.js input.oninput, of which 12ms was forced style and layout.” That last figure points straight at a forced synchronous layout hiding inside the handler.

Why It Supersedes longtask for INP

INP is dominated by the worst interaction’s three phases: input delay, processing time, and presentation delay. A longtask entry overlaps the processing phase but ignores presentation delay and gives no attribution, so it cannot tell you whether the slow part was your event handler or the rendering that followed. LoAF spans the whole frame β€” renderStart separates script time from render time β€” and its scripts array names the culprit. That is exactly the breakdown you need when correlating with the per-interaction data from Measuring INP with the Event Timing API.

Laid over the same interaction, the coverage gap is obvious: longtask clips the processing phase and stops, while LoAF wraps input delay through presentation and carries per-script attribution alongside it.

longtask versus LoAF coverage of an interaction Two rows over the same INP timeline showing longtask covering only the processing phase while LoAF spans input delay, processing, and presentation with script attribution. Same interaction, two signals input delay processing time presentation delay longtask covers processing only β€” no attribution LoAF spans the whole frame β€” renderStart splits script vs render scripts[] names search.js input.oninput

Debugging Trace

[Long Animation Frame β€” 'input' interaction]
  frame start ............................. t=0
β”œβ”€ scripts[0] search.js input.oninput .... 90.0ms
β”‚    └─ forcedStyleAndLayoutDuration ...... 12.0ms  (sync layout inside filter)
β”œβ”€ renderStart .......................... t=90ms   ← paint delayed 90ms
β”œβ”€ style + layout ........................ 4.0ms
└─ paint ................................. 3.0ms
   duration: 97ms  blockingDuration: 47ms
   Frame budget 16.6ms exceeded β€” INP for this interaction β‰ˆ 97ms+

The Fix

Move the heavy work off the rendering frame: yield so the input can paint an immediate acknowledgement, then compute. Splitting the synchronous slice both shrinks blockingDuration and lets the result paint progressively.

input.addEventListener('input', (e) => {
  state.query = e.target.value
  showSpinner() // βœ… cheap synchronous update paints this frame
  // Defer the expensive filter out of the rendering frame
  queueMicrotask(async () => {
    results = await filterInChunks(catalogue, state.query) // yields between chunks
    renderResults(results) // paints in a later, short frame
  })
})

If forcedStyleAndLayoutDuration is the dominant slice, the real fix is batching DOM reads and writes so the handler stops flushing layout mid-loop β€” the LoAF entry has already located it for you.

The structural change is turning one blocking frame into a short acknowledgement frame plus deferred chunks, each short enough to slip under the 50ms LoAF threshold:

Before and after yielding the heavy work The before path shows one 97ms blocking frame; the after path shows a cheap acknowledgement frame followed by deferred chunks that each render quickly. Before β€” one blocking frame handler + filter + render 97ms β€” blockingDuration 47ms input frozen until paint first paint After β€” yield, then chunk showSpinner() cheap, paints now chunk 1 <16ms frame chunk 2 <16ms frame results

Verification Checklist

metric target how measured
blockingDuration per interaction frame < 50ms long-animation-frame observer
forcedStyleAndLayoutDuration ~0ms scripts[].forcedStyleAndLayoutDuration
INP (field) < 200ms Event Timing correlated to LoAF
Render start after input < 16.6ms renderStart βˆ’ frame start

Why LoAF Beats Long Tasks for Animation Work

The Long Animation Frames API exists because the older Long Tasks API answers the wrong question for rendering work. A long task flags any single script execution over 50ms, but jank is rarely one 50ms task β€” it is often a frame made slow by several shorter scripts, a forced layout, and a heavy style recalculation that individually stay under the long-task threshold yet together blow the frame budget. LoAF measures the whole frame: it reports when a rendering update took too long regardless of how the time was distributed, and it attributes the cost across scripts, style, layout, and paint. That framing matches how animation actually fails, which is why it is the better instrument for tracking smoothness.

The attribution LoAF provides is what makes it actionable. Each entry carries the frame’s start time, its duration, the time blocked, and a breakdown of the scripts that ran, including their source location and whether they were classic event listeners, promise resolutions, or timers. In the field this turns a vague β€œthe page feels janky” into β€œthese specific handlers are extending frames past budget on this route,” which is a work item rather than a mystery. Pairing LoAF with the INP attribution from the Event Timing API gives a near-complete picture of interaction smoothness: one tells you which frames ran long and why, the other tells you how that translated into the latency a user felt when they interacted.

Frequently Asked Questions

What is the difference between duration and blockingDuration on a LoAF entry?

duration is the full wall-clock length of the animation frame, from frame start through paint. blockingDuration is only the portion that blocked input beyond the 50ms allowance β€” the number that actually tracks with interaction latency. A 97ms frame can carry a 47ms blockingDuration, and it is that second figure you drive toward zero.

Which browsers support the long-animation-frame entry type?

LoAF shipped in Chromium-based browsers (Chrome and Edge) from version 123. Safari and Firefox do not implement it yet, so feature-detect with PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') and keep a longtask fallback for non-Chromium field traffic.

Why does a LoAF entry sometimes have an empty scripts array?

Attribution is only populated when a script actually ran inside the frame. A frame that went long purely from rendering or style and layout β€” heavy CSS recalc, a large layout pass β€” records the time in renderStart and styleAndLayoutStart but leaves scripts empty, telling you to look at the render pipeline rather than a handler.

Does observing LoAF add measurable overhead to the page?

No. Like other PerformanceObserver entry types it is collected by the browser regardless and delivered in batches, so the observer callback runs off the critical path. Keep the callback itself cheap β€” map fields and hand them to your beacon, never do synchronous DOM work inside it.