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.
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.
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.
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 |
- No
long-animation-frameentry withblockingDuration > 50ms - The
scripts -
forcedStyleAndLayoutDuration