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:
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.
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:
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.
Related Guides
- PerformanceObserver API Patterns β the parent guide covering buffered flags, entry types, and observer lifecycle.
- Measuring INP with the Event Timing API β correlate the per-interaction latency that LoAF explains.
- Forced Synchronous Layouts β fix the
forcedStyleAndLayoutDurationslice a LoAF entry exposes.