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-frame entry with blockingDuration > 50ms
  • The scripts
  • forcedStyleAndLayoutDuration