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.

// 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.

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.

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.

// βœ… 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
  • No longtask
  • requestAnimationFrame