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 |
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.
Related Guides
- PerformanceObserver API Patterns β the parent guide covering buffered replay, entry types, and observer lifecycle.
- Automating Lighthouse CI Performance Budgets β turn the TBT signal from long tasks into a CI gate.
- Forced Synchronous Layouts β the layout-thrash pattern that hides inside many long tasks.
- Rendering Performance Metrics and Tooling β how field metrics and lab tooling fit together.