Long Task & Main-Thread Attribution
A slow interaction is almost never slow because of the event handler you wrote — it is slow because the main thread was already busy when the input arrived, or because something downstream of your handler blocked the next paint. To fix that in production you have to attribute the blocking: identify which script, which third party, and which frame ate the budget. This page, part of Custom Metrics & Business Impact Tracking, covers how to measure main-thread blocking in the field with the Long Tasks API and the newer Long Animation Frames (LoAF) API, attribute each block to a source, and feed both into your RUM pipeline so that blocking regressions surface before they degrade interaction responsiveness measured as INP.
Total Blocking Time is the lab proxy for all of this, but the lab only sees the synthetic run; the field is where the long tasks that actually hurt users live. The job of this page is to make those field-side blocks attributable and to wire them into the same beacon and p75 aggregation pipeline you already use for the headline vitals — without drowning in opaque third-party noise.
What counts as blocking, and how each API sees it
Three distinct measurements describe main-thread blocking, and they do not agree with each other — they observe different windows and attribute at different granularities.
A long task is any contiguous run of work on the main thread that exceeds 50 ms, surfaced through PerformanceObserver with entryType: 'longtask'. It tells you that the thread was blocked and for how long, and it carries a TaskAttributionTiming child in its attribution array with containerType, containerName, and containerSrc. The hard limitation: that container is the frame (the document or an iframe), not the script. A long task triggered by your analytics vendor inside the top document reports containerType: 'window' and tells you nothing about which <script> was responsible.
A long animation frame (LoAF) is the richer successor. With entryType: 'long-animation-frame' you get any animation frame whose total work exceeds 50 ms, plus a scripts[] array where each entry exposes sourceURL, invoker (such as 'IMG#hero.onload' or 'event-listener'), invokerType, and a per-script duration, forcedStyleAndLayoutDuration, and pauseDuration. LoAF also breaks the frame into renderStart, styleAndLayoutStart, and blockingDuration, so you can separate “your JS was slow” from “your JS forced a synchronous layout that was slow.”
Total Blocking Time (TBT) is the sum, between First Contentful Paint and Time to Interactive, of the portion of every long task that exceeds 50 ms. It is a lab metric — Lighthouse and WebPageTest compute it from a synthetic trace. It correlates strongly with field INP, which makes it the practical CI gate, but it never sees real user input timing.
| Signal | API / source | Attribution granularity | Where it runs | Primary use |
|---|---|---|---|---|
| Long Task | PerformanceObserver type longtask |
Container frame only (containerType/containerName/containerSrc) |
Field (RUM) | Count and total blocking time per page; widely supported |
| Long Animation Frame | PerformanceObserver type long-animation-frame |
Per-script (sourceURL, invoker, forcedStyleAndLayoutDuration) |
Field (RUM) | Attribute blocking to a specific script or third party |
| Total Blocking Time | Lighthouse / WebPageTest trace | Per-call-tree (lab trace) | Lab / CI | Regression gate before deploy |
| Blocking → INP | LoAF blockingDuration overlapping an interaction |
Per-interaction | Field (RUM) | Explain why a specific interaction was slow |
The thresholds you act on are not the metric’s own “Good/Poor” bands — long tasks have no official band — but the engineering triggers below, which most teams adopt because they map cleanly onto the INP Good ≤ 200 ms / Needs Improvement ≤ 500 ms / Poor > 500 ms thresholds.
| Field observation (p75) | Status | Engineering action |
|---|---|---|
| No long task > 50 ms near interactions | Good | Monitor only; gate TBT in CI |
Long tasks present but blockingDuration < 50 ms |
Needs Improvement | Break up the largest task with scheduler.yield |
LoAF blockingDuration 50–200 ms overlapping inputs |
Needs Improvement | Defer or yield in the attributed sourceURL; move work off the interaction path |
LoAF blockingDuration > 200 ms, or single task > 200 ms |
Poor | Code-split, lazy-load, or sandbox the attributed script (often a third party) |
Instrumenting both observers and shipping to RUM
Register the LoAF observer when supported and fall back to long tasks elsewhere; never assume one entry type covers all browsers. Use buffered: true so you capture entries that fired before your script ran — the same buffered-entry pattern used across the web-vitals API and PerformanceObserver setup. Finalize on visibilitychange/pagehide, because the most damaging blocking often happens late in the session and a tab can be discarded without a pagehide-only flush firing reliably.
// main-thread-attribution.js — capture longtask + LoAF and beacon to RUM.
const SAMPLE_RATE = 0.1; // session-level rate; see the sampling cluster.
const sessionSampled = Math.random() < SAMPLE_RATE;
const blocking = {
longTaskCount: 0,
longTaskTotalMs: 0,
loafTotalBlockingMs: 0,
worstFrameMs: 0,
topScripts: new Map(), // sourceURL -> accumulated blocking ms
};
function recordScript(url, ms) {
if (!url) url = '(anonymous)';
blocking.topScripts.set(url, (blocking.topScripts.get(url) || 0) + ms);
}
const supports = (type) =>
PerformanceObserver.supportedEntryTypes &&
PerformanceObserver.supportedEntryTypes.includes(type);
if (supports('longtask')) {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
blocking.longTaskCount += 1;
blocking.longTaskTotalMs += entry.duration;
// Container attribution is frame-level only.
const attr = entry.attribution && entry.attribution[0];
if (attr && attr.containerSrc) recordScript(attr.containerSrc, entry.duration);
}
}).observe({ type: 'longtask', buffered: true });
}
if (supports('long-animation-frame')) {
new PerformanceObserver((list) => {
for (const frame of list.getEntries()) {
blocking.loafTotalBlockingMs += frame.blockingDuration || 0;
blocking.worstFrameMs = Math.max(blocking.worstFrameMs, frame.duration);
// Per-script attribution: this is what longtask cannot give you.
for (const script of frame.scripts || []) {
const cost = script.duration + (script.forcedStyleAndLayoutDuration || 0);
recordScript(script.sourceURL || script.invoker, cost);
}
}
}).observe({ type: 'long-animation-frame', buffered: true });
}
function flush() {
if (!sessionSampled || blocking.longTaskCount === 0 && blocking.loafTotalBlockingMs === 0) return;
const top = [...blocking.topScripts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([url, ms]) => ({ url, ms: Math.round(ms) }));
const payload = JSON.stringify({
kind: 'main_thread_blocking',
path: location.pathname,
longTaskCount: blocking.longTaskCount,
longTaskTotalMs: Math.round(blocking.longTaskTotalMs),
loafTotalBlockingMs: Math.round(blocking.loafTotalBlockingMs),
worstFrameMs: Math.round(blocking.worstFrameMs),
topScripts: top,
deviceMemory: navigator.deviceMemory || null,
connection: (navigator.connection || {}).effectiveType || null,
});
navigator.sendBeacon('/rum', payload);
}
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
addEventListener('pagehide', flush);
The topScripts map is the payload that earns its bytes: it sends the few worst sources rather than every frame, keeping the beacon small while preserving attribution. Send it through the same endpoint as your other beacons; the ingestion and storage concerns are the same ones covered in the broader User Timing API: Marks & Measures work, where custom timings share a schema with blocking metrics.
Debugging workflow
When the field p75 shows blocking-driven slow interactions, work the problem in this order rather than guessing at the code:
- Identify the regression in RUM. Segment slow INP sessions and check whether
loafTotalBlockingMsat p75 moved. If blocking is flat but INP rose, the cause is presentation delay, not main-thread work — stop here and look elsewhere. - Pull the attributed scripts. Aggregate
topScriptsfor the affected route and rank by total blocking ms. A singlesourceURLusually dominates. - Reproduce in the lab with the Performance panel. Record an interaction trace in Chrome DevTools; long animation frames render as a dedicated track, and clicking a frame shows the same
scripts[]you captured in the field. - Correlate the overlap. Confirm the long animation frame’s window actually overlaps the interaction’s input delay or processing — a long task that happens between interactions does not hurt INP and is a lower priority.
- Apply the fix and validate TBT in the lab. Whether you code-split, defer the third party, or yield, re-run Lighthouse and confirm Total Blocking Time dropped before shipping.
- Monitor the field delta. After deploy, watch
loafTotalBlockingMsp75 and the attributedsourceURL’s share for the next few days; lab improvements do not always reproduce in the field on low-end devices.
Field-data segmentation
Aggregate blocking at p75, never the mean — a handful of catastrophic frames on cold cache will pull the average somewhere no real user sits. The cohorts that consistently expose blocking:
- Device class. Split on
navigator.deviceMemoryand hardware concurrency. A script that costs 30 ms on an unthrottled laptop routinely costs 180 ms on a sub-4 GB Android device, which is exactly the population that fails INP. Blocking is the most device-sensitive of all the vitals inputs. - Network type.
effectiveTypeof3g/slow-2gcorrelates with blocking indirectly: slow networks delay third-party scripts so they execute later, landing their long tasks during user interaction rather than during idle load. - Geography and route. Ad-heavy and consent-flow routes carry far more third-party blocking. Segment by
location.pathnametemplate, not raw URL. - First vs repeat view. Cold-cache first views pay parse/compile cost that repeat views skip; reporting them together hides regressions in the cohort that matters for acquisition.
Watch for the divergence where overall blocking is fine but the low-memory mobile segment is Poor. That is the normal shape of an INP problem, and it is invisible in a single blended number.
Optimization strategies with before/after impact
- Break up the offending task. Replacing a synchronous 320 ms render loop with
scheduler.yield()between chunks typically turns one 320 ms long task into six sub-50 ms tasks;blockingDurationoverlapping interactions drops from ~270 ms to under 50 ms even though total CPU is unchanged. - Defer and sandbox third parties. Moving an analytics or tag-manager bundle behind
requestIdleCallbackor into a worker commonly removes the largestsourceURLfrom yourtopScriptsranking outright, cuttingloafTotalBlockingMsp75 by 40–60% on ad-heavy routes. - Kill forced synchronous layout. When LoAF shows a high
forcedStyleAndLayoutDuration, batch DOM reads before writes; eliminating a layout thrash inside a scroll handler can take a 90 ms frame back under 30 ms with no change to the JS itself.
Failure modes and gotchas
- Third-party opacity in
longtask. Cross-origin scripts inside the top document reportcontainerType: 'window'with no usefulcontainerSrc. The long-task API genuinely cannot attribute them — this is the single biggest reason to prefer LoAF, whosescripts[].sourceURLsurvives cross-origin (subject to the script being same-origin or served with appropriate CORS for full URLs). - Browser support.
longtaskis broadly available in Chromium. LoAF is Chromium-only as of this writing; Safari and Firefox expose neither LoAF norlongtask, so a meaningful share of your traffic reports no blocking data. Treat absence as “unknown,” not “zero,” and never compute a blocking p75 across browsers that cannot emit it. - Cross-origin URL truncation. Even in LoAF, a cross-origin script without proper CORS headers may report a coarse or empty
sourceURL. AddTiming-Allow-Originwhere you control the third party. - Buffered-entry double counting. If you register the observer twice (for example, once in a framework plugin and once in your RUM snippet) you will double-count buffered entries. Register once.
- SPA route transitions. Long tasks are not reset on client-side navigation. Tag entries with the current route at capture time, or a heavy task on route A will be attributed to route B.
CI/CD gating
Gate the lab proxy, because the field signal arrives too late to block a deploy. Total Blocking Time is the right gate: it is deterministic in a controlled environment and tracks the field INP you ultimately care about.
# Fail the build if median TBT regresses past budget (Lighthouse CI).
lhci collect --url=https://staging.example.com/ --numberOfRuns=5
lhci assert --assertions.total-blocking-time="error,maxNumericValue=200"
Pair the lab gate with a field guardrail: alert when loafTotalBlockingMs p75 for the low-memory mobile segment crosses your Needs Improvement line, using the same percentile machinery as the rest of your vitals. For the deep mechanics of the long-task entry itself — buffering, attribution fields, and edge timing — see Measuring Long Tasks with the Long Tasks API. To connect blocking back to revenue, the Conversion Funnel Correlation work overlays these blocking segments onto funnel drop-off.
FAQ
Should I use the Long Tasks API or Long Animation Frames?
Use both. Long Tasks is broadly supported and gives you blocking duration everywhere Chromium runs, but it can only attribute to a frame. LoAF gives per-script attribution (sourceURL, invoker, forced layout) and is what you actually debug with — but it is Chromium-only, so it cannot replace the long-task fallback.
Why is Total Blocking Time a lab metric if blocking happens in the field?
TBT is defined over the window between First Contentful Paint and Time to Interactive in a single synthetic trace, so it has no notion of real user input. It is valuable precisely because it is deterministic and correlates with field INP, which makes it a reliable pre-deploy gate; the field equivalent is LoAF blockingDuration overlapping real interactions.
Why does a long task have no sourceURL?
The Long Tasks API attributes only to the containing frame via TaskAttributionTiming, not to the script. Cross-origin third-party scripts in the top document therefore report containerType: 'window' with no script URL. Switch to the LoAF scripts[] array, which exposes sourceURL per script.
How does main-thread blocking actually change INP?
INP is input delay plus processing plus presentation delay. A long task already running when input arrives inflates input delay; slow handler work inflates processing; a forced synchronous layout inflates presentation. LoAF measures the whole animation frame around the interaction, so its blockingDuration is the most direct field explanation of a slow INP.
What sampling rate should I send blocking beacons at?
Sample at the session level so a session’s blocking story stays internally consistent, then reweight at query time. The rate is a cost-versus-resolution trade-off covered in the RUM data sampling strategies work; 10% is a common starting point for high-traffic sites.
Related
- Measuring Long Tasks with the Long Tasks API — the entry-level mechanics of the
longtaskobserver and its attribution fields. - INP Tracking & Debugging — how blocking surfaces as slow interactions and how to debug the interaction itself.
- Breaking Up Long Tasks with scheduler.yield — the primary fix once you have attributed the blocking work.
- User Timing API: Marks & Measures — custom timings that share a beacon schema with blocking metrics.
- Element Timing API — pairing render timing for hero elements with the blocking that delays them.