Observing Long Tasks with PerformanceObserver

The longtask entry type reports any block of main-thread work that ran for 50ms or longer without yielding, which is the canonical signal for input that feels stuck and frames that get skipped. This builds on PerformanceObserver API Patterns, part of Rendering Performance Metrics and Tooling, and it is the field-data foundation under the lab metric Total Blocking Time.

The 50ms Threshold

The browser considers a task β€œlong” once it occupies the main thread for at least 50ms. The number is not arbitrary: while a task runs, the thread cannot service input, run requestAnimationFrame callbacks, or composite. Past ~50ms, the user perceives the interface as unresponsive and any animation in flight has already missed several 16.6ms frames. Anything below 50ms is not emitted as a long task at all, so the entry type is a coarse but reliable β€œthe thread was stuck” alarm.

A 118ms task against the 16.6ms frame budget One long task spans seven frame slots, crossing the 50ms long-task threshold and dropping every frame it overlaps. One 118 ms task vs the 16.6 ms frame budget Long task β€” 118 ms Β· main thread blocked frame frame frame frame frame frame frame 50 ms threshold 7 frames missed β€” no rAF, no composite until the task returns
// Minimal reproduction: one synchronous loop that blocks for ~120ms
button.addEventListener('click', () => {
  const data = []
  for (let i = 0; i < 5_000_000; i++) {
    data.push(Math.sqrt(i) * Math.random()) // ❌ never yields β€” main thread frozen
  }
  render(data) // input, rAF, and compositing are all starved until this returns
})

Reading the Entry and Its Attribution

A longtask entry gives you startTime, duration, and an attribution array. The attribution does not name the exact function β€” that would require source-level instrumentation β€” but it does tell you the container responsible: the same-origin frame, an <iframe>, or an embedded ad. name is one of self, same-origin-descendant, cross-origin-descendant, and so on, which is enough to decide whether the stall is your code or a third party.

longtask entry fields and attribution names The entry object carries startTime, duration and an attribution array whose name field resolves to self, same-origin, or cross-origin containers. longtask entry entryType: 'longtask' startTime: 4210.2 duration: 118 ms attribution: [ … ] name: 'self' your own frame's script name: 'same-origin-descendant' a same-origin iframe name: 'cross-origin-descendant' third-party ad / iframe
const obs = new PerformanceObserver((list) => {
  for (const task of list.getEntries()) {
    const attr = task.attribution[0] // TaskAttributionTiming
    report({
      duration: task.duration,        // ms the thread was blocked
      blockingTime: task.duration - 50, // contribution to TBT
      container: attr?.containerType,  // 'window' | 'iframe' | ...
      src: attr?.containerSrc,         // which frame, if any
    })
  }
})
obs.observe({ type: 'longtask', buffered: true }) // replay tasks fired during load

Relation to Total Blocking Time

Total Blocking Time sums, across every long task in a window, the part of each task that exceeds 50ms. A 120ms task contributes 70ms of blocking time; a 51ms task contributes 1ms. This is why one monolithic task is far worse than several short ones of the same total length: the threshold subtraction punishes long tasks super-linearly. TBT measured in the lab and the live longtask stream are two views of the same underlying main-thread occupancy, which is why long tasks correlate so tightly with the Total Blocking Time that lab tooling reports β€” see Automating Lighthouse CI Performance Budgets.

Blocking time of one monolith vs three short tasks A single 120ms task contributes 70ms of blocking time, while three 40ms tasks of the same total length contribute zero. Blocking time = per-task duration βˆ’ 50 ms Monolith first 50 ms 70 ms blocking β†’ 70 Chunked 40 ms 40 ms 40 ms β†’ 0 50 ms line yellow = time counted toward TBT

Debugging Trace

In a Performance recording, a long task appears as a single wide block in the Main lane with a red corner flag. The PerformanceObserver output lines up with it:

[Main Thread β€” click handler]
β”œβ”€ Event: click .......................... 0.3ms
β”œβ”€ Task (Function Call) ................. 118.0ms  β–£ LONG TASK (red flag)
β”‚    └─ blocking time = 118 βˆ’ 50 = 68ms  β†’ added to TBT
β”œβ”€ (rAF callback delayed) ................ queued, not run
└─ Composite Layers ...................... blocked until task returns
   Frame total: 118.6ms β€” 7 frames dropped at 60fps

PerformanceObserver longtask entry:
  { startTime: 4210.2, duration: 118, attribution: [{ containerType: 'window' }] }

The seven dropped frames are the visible symptom; the single 118ms task is the cause.

The Fix: Break Up the Task

Yield to the event loop so the browser can interleave input, rendering, and your work. The cleanest primitive is scheduler.yield() where available, falling back to chunking with setTimeout or await-ing a message-channel turn. The goal is that no synchronous slice exceeds the frame budget.

Splitting one blocking loop into yielding chunks A single synchronous loop becomes four short chunks separated by yield points where input and compositing run. Before: one blocking task for 5,000,000 { … } 118 ms Β· never yields input Β· rAF Β· composite all starved yield After: chunked with scheduler.yield() chunk chunk chunk chunk ↕ ↕ ↕ thread yields in the gaps β€” input and composite run
// βœ… Each chunk stays under one frame; the thread yields between chunks
button.addEventListener('click', async () => {
  const data = []
  const CHUNK = 100_000
  for (let i = 0; i < 5_000_000; i++) {
    data.push(Math.sqrt(i) * Math.random())
    if (i % CHUNK === 0) {
      // Hand the main thread back so input + compositing can run
      if ('scheduler' in window && scheduler.yield) await scheduler.yield()
      else await new Promise((r) => setTimeout(r))
    }
  }
  render(data)
})

For genuinely CPU-bound work, move the loop into a Worker so the main thread never blocks at all. Long tasks frequently hide a second, sneakier cost β€” a forced synchronous layout inside the loop, where reading offsetTop after a style write flushes layout on every iteration. If chunking alone does not flatten the task, profile the slice for interleaved Layout events and batch the reads and writes.

Verification Checklist

metric target how measured
Longest single long task < 50ms (none emitted) longtask observer in field
TBT (lab) < 200ms Lighthouse on throttled CPU
Dropped frames during interaction 0 Performance panel frame strip
Attribution containerType window resolved observer entry inspection

What the Attribution Field Can and Cannot Tell You

The Long Tasks API flags any task over 50ms, but its attribution array is coarser than people expect: it identifies the container (the top-level document or an iframe) responsible for the task, not the exact function. That is enough to answer β€œis this our code or a third-party frame?” β€” a genuinely useful triage question β€” but not β€œwhich handler?” For finer attribution you pair it with the Long Animation Frames API, which breaks a slow frame down by script and source location. The practical pattern is to use Long Tasks as a cheap, widely-supported alarm that something blocked the main thread, then reach for LoAF or a Performance trace to localise the cause once the alarm fires.

Frequently Asked Questions

Why does the longtask API only report tasks over 50ms?

50ms is the point at which a stalled main thread becomes user-perceptible: the thread has already missed roughly three 16.6ms frames and cannot service input. Reporting shorter tasks would flood the observer with noise from ordinary work, so the spec fixes the floor at 50ms and emits nothing below it.

Can PerformanceObserver tell me which function caused the long task?

No. The attribution array only names the responsible container β€” self, a same-origin iframe, or a cross-origin descendant such as an ad β€” not the call site. To find the exact function you still need a Performance-panel recording or a PerformanceObserver on long-animation-frame, which carries script-level attribution.

How is long-task duration related to Total Blocking Time?

Each long task contributes duration βˆ’ 50ms to TBT. A 120ms task adds 70ms; a 51ms task adds 1ms. TBT sums that surplus across every long task in the window, which is why breaking one monolith into sub-50ms chunks can drop TBT to zero without reducing total CPU work.

Does scheduler.yield() actually reduce long tasks or just move them?

It splits them. Each await scheduler.yield() ends the current task and posts a continuation, so the browser can run input, requestAnimationFrame, and compositing between chunks. As long as no single chunk exceeds 50ms, the longtask observer stops firing even though the total work is unchanged.

Should I set buffered to true when observing long tasks?

Yes, for load-time analysis. buffered: true replays long tasks that fired before your observer was registered, which matters because the worst stalls often happen during initial script evaluation and hydration, before any of your instrumentation is attached.