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