Measuring Long Tasks with the Long Tasks API

A route can pass its Core Web Vitals thresholds in synthetic tests and still feel sluggish in the field, because the thing users actually feel — the main thread refusing to respond — is invisible to page-load metrics. The Long Tasks API surfaces it directly: every block of main-thread work that runs for more than 50 ms is reported as a longtask entry, and the newer Long Animation Frames (LoAF) API attributes those blocks down to the individual scripts that caused them. This page is a concrete recipe — observe both entry types with PerformanceObserver, read their attribution, aggregate blocking time per route, and ship the result to your monitoring backend. It extends the field-attribution methods established in the parent Long Task & Main-Thread Attribution guide.

Long tasks are the raw material behind a high Interaction to Next Paint (INP): a task that occupies the main thread when a tap lands is exactly the delay a user experiences. Measuring long tasks tells you where the blocking is; fixing it usually means yielding the thread, which the Breaking Up Long Tasks with scheduler.yield recipe covers. The observer plumbing here is the same pattern documented in the Web Vitals API Implementation cluster, so if you have already wired up a PerformanceObserver for vitals, this slots in alongside it.

From longtask and LoAF entries to per-route blocking time in RUM PerformanceObserver receives longtask entries with container attribution and long-animation-frame entries with script attribution, both feed a per-route blocking-time aggregator, which beacons totalBlockingTime to the RUM backend. Main-thread events longtask > 50 ms attribution: container LoAF frame scripts: sourceURL PerformanceObserver buffered: true per-route aggregate sum blocking time RUM backend p75 by route Blocking time = max(0, duration - 50 ms) per entry, summed across the route visit. LoAF adds scripts[] so you learn which file owns the block, not just that one exists.
Both entry types feed one observer; the aggregate is finalized on page hide and reported. See the parent attribution cluster for the broader pattern.

Prerequisites

Before wiring this up, confirm the following are in place:

  • A way to identify the current route in a single-page app (a router hook or location.pathname snapshot). Per-route aggregation is meaningless without it.
  • An existing beacon path to your RUM ingestion endpoint. This recipe reuses it rather than inventing a new transport.
  • A target browser matrix you have checked: longtask ships in all Chromium browsers; long-animation-frame is Chromium-only (123+); neither is implemented in Safari or Firefox at time of writing, so both must be feature-detected.
  • Familiarity with PerformanceObserver and buffered entries — long tasks fired before your script ran are only recoverable with buffered: true.

How to measure long tasks and report them

Step 1 — Feature-detect both entry types

Never assume support. PerformanceObserver.supportedEntryTypes is the authoritative check and avoids the exception that observe() throws on an unknown type.

const supports = (type) =>
  typeof PerformanceObserver !== 'undefined' &&
  PerformanceObserver.supportedEntryTypes &&
  PerformanceObserver.supportedEntryTypes.includes(type);

const hasLongTask = supports('longtask');
const hasLoAF = supports('long-animation-frame');

Why: reading supportedEntryTypes is a read-only probe with no side effects, whereas calling observe({ type: 'long-animation-frame' }) on a browser that lacks it raises a TypeError that, uncaught, can abort the rest of your init script. Detecting each type independently lets you run with longtask alone on browsers that have it but not LoAF.

Step 2 — Set up the per-route aggregator

Long tasks are only actionable when grouped. The metric that maps cleanly to user pain is blocking time: the portion of each task beyond the 50 ms budget. Summed over a route visit this is your Total Blocking Time for that route.

const routeState = {
  route: location.pathname,
  totalBlockingTime: 0,
  longTaskCount: 0,
  worstScript: { url: null, duration: 0 },
};

function blockingTimeOf(durationMs) {
  return Math.max(0, durationMs - 50);
}

function resetRoute(newRoute) {
  routeState.route = newRoute;
  routeState.totalBlockingTime = 0;
  routeState.longTaskCount = 0;
  routeState.worstScript = { url: null, duration: 0 };
}

Why: raw duration over-counts — a 51 ms task and a 50 ms task are effectively equal in user impact, so only the excess above 50 ms is charged. Math.max(0, …) guards LoAF frames whose duration can legitimately fall at or below 50 ms when you observe them for their script attribution rather than their blocking.

Step 3 — Observe longtask for coarse container attribution

The classic longtask entry tells you a block happened and gives a coarse attribution[] pointing at the embedding container (the document, an iframe, etc.) via containerType and containerSrc.

let longTaskObserver;
if (hasLongTask) {
  longTaskObserver = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      routeState.totalBlockingTime += blockingTimeOf(entry.duration);
      routeState.longTaskCount += 1;

      const attr = entry.attribution && entry.attribution[0];
      if (attr && attr.containerType === 'iframe' && attr.containerSrc) {
        // A third-party iframe owns this block — note it for triage.
        routeState.worstScript.url ??= attr.containerSrc;
      }
    }
  });
  longTaskObserver.observe({ type: 'longtask', buffered: true });
}

Why: buffered: true replays long tasks that fired during the costly early phase of page load — exactly the window you most want to measure — before your observer was attached. The attribution array is deliberately coarse: it identifies the frame responsible, not the function, which is why the next step exists.

Step 4 — Observe long-animation-frame for script-level attribution

LoAF is the upgrade. Each entry covers a rendering frame that took too long and exposes a scripts[] array with sourceURL, invoker, invokerType, and per-script duration — enough to name the file and entry point responsible.

let loafObserver;
if (hasLoAF) {
  loafObserver = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // Charge blocking time once per LoAF frame.
      routeState.totalBlockingTime += blockingTimeOf(entry.duration);

      for (const script of entry.scripts || []) {
        if (script.duration > routeState.worstScript.duration) {
          routeState.worstScript = {
            url: script.sourceURL || script.invoker || 'unknown',
            duration: Math.round(script.duration),
          };
        }
      }
    }
  });
  loafObserver.observe({ type: 'long-animation-frame', buffered: true });
}

Why: a LoAF frame is a superset of the long-task concept — it captures the whole animation frame including style and layout, not just script — so where LoAF is available it is the more accurate blocking signal. Because a single frame can contain several scripts, iterating scripts[] lets you attribute the cost to the single worst offender, which is what an engineer needs to open the right file. Guard with entry.scripts || [] because frames with no attributable script report an empty or absent array.

Step 5 — Avoid double-counting when both observers run

On Chromium you will have both observers firing for overlapping work. Pick LoAF as the source of truth for blocking time when it is present, and let longtask provide only container attribution.

// Re-run Step 3's longtask handler body WITHOUT the blocking-time line
// when LoAF is active, so blocking time is charged exactly once.
if (hasLongTask) {
  longTaskObserver = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (!hasLoAF) {
        routeState.totalBlockingTime += blockingTimeOf(entry.duration);
      }
      routeState.longTaskCount += 1;
      const attr = entry.attribution && entry.attribution[0];
      if (attr && attr.containerSrc) {
        routeState.worstScript.url ??= attr.containerSrc;
      }
    }
  });
  longTaskObserver.observe({ type: 'longtask', buffered: true });
}

Why: without this guard a Chromium session adds the same block twice — once per observer — and your reported blocking time roughly doubles, silently inflating the metric. Keeping longTaskCount from the longtask observer is still useful because it is a stable cross-version count even on browsers without LoAF.

Step 6 — Finalize and beacon on route change and page hide

The aggregate is only complete when the user leaves the route or the page. Flush on both, and reset for SPA navigations.

function flushRoute() {
  if (routeState.longTaskCount === 0 && routeState.totalBlockingTime === 0) return;
  const payload = JSON.stringify({
    metric: 'main_thread_blocking',
    route: routeState.route,
    totalBlockingTime: Math.round(routeState.totalBlockingTime),
    longTaskCount: routeState.longTaskCount,
    worstScriptUrl: routeState.worstScript.url,
    worstScriptDuration: routeState.worstScript.duration,
  });
  navigator.sendBeacon('/rum/collect', payload);
}

// Final flush — pagehide is the most reliable lifecycle hook for this.
addEventListener('pagehide', flushRoute, { once: false });

// SPA route change: flush the old route, then start a fresh aggregate.
function onRouteChange(nextPath) {
  flushRoute();
  resetRoute(nextPath);
}

Why: pagehide fires on bfcache eviction and real unloads where unload does not, and sendBeacon survives the page being torn down because the browser keeps the request alive. Sending one beacon per route — rather than per task — keeps payload volume low and lets the backend compute p75 blocking time by route, which is the aggregate that correlates with INP. Calling flushRoute() before resetRoute() on navigation prevents one route’s blocking from bleeding into the next.

Verifying it works

Confirm the pipeline end to end:

  • DevTools Performance panel: record a trace, then look at the Main track. Long tasks render with a red corner flag; LoAF frames appear under the “Long animation frames” lane in recent Chrome. The durations there should match what your observer charges (minus the 50 ms budget).
  • Console probe: temporarily add console.table(routeState) inside flushRoute before the beacon. Trigger a deliberately heavy interaction (a for loop that runs ~200 ms on click) and confirm totalBlockingTime jumps by roughly 150 ms and worstScriptUrl names your bundle.
  • Network panel: filter to your collector path and confirm exactly one beacon per route on navigation and one on tab close, with the expected JSON body. No beacon should fire on a route with zero long tasks.
  • RUM dashboard: after deploy, the per-route blocking-time series should rank routes; the worst routes here should line up with the worst routes in your INP report. A route that is high on one and low on the other is a signal worth investigating.

The threshold context for interpreting the numbers:

Signal Good Needs improvement Poor Engineering action
INP (the user-felt outcome) ≤ 200 ms ≤ 500 ms > 500 ms Reduce blocking on the interacting route
Per-route Total Blocking Time < 200 ms 200–600 ms > 600 ms Yield or defer the worst script
Single long-task duration ≤ 50 ms (not a task) 50–100 ms > 100 ms Split the task into chunks

Edge cases & gotchas

  • Cross-origin scripts report opaquely. A LoAF script.sourceURL from a cross-origin file without a permissive Timing-Allow-Origin / resource access policy may be blank or coarse. Treat an empty sourceURL as “third-party” rather than discarding the entry — the blocking time is still real.
  • Safari and Firefox report nothing. Both feature checks return false there, so those sessions contribute zero long-task data. Do not interpret a route’s silence as “fast”; segment your dashboard by browser so the absence is explicit and you do not under-count blocking across your whole population.
  • Background tabs suspend the main thread. A tab throttled in the background can produce a single multi-second “long task” on resume that is an artifact, not real blocking. Cap or drop entries whose duration is implausibly large (e.g. > 5000 ms) before charging blocking time.
  • LoAF can double the 50 ms subtraction. Because a LoAF frame and a longtask describe overlapping work, the Step 5 guard is mandatory — without it the same block is counted by both observers and your metric inflates.
  • SPA route attribution races the observer callback. Observer callbacks are dispatched asynchronously, so an entry from the previous route can arrive after onRouteChange. If precise boundaries matter, stamp each entry against entry.startTime and the route active at that timestamp rather than the current routeState.route.

FAQ

What is the difference between the Long Tasks API and LoAF?

The longtask entry reports any main-thread task over 50 ms with coarse container-level attribution. long-animation-frame (LoAF) reports a whole over-budget rendering frame and adds a scripts[] array naming the source files and invokers responsible — much richer, but Chromium-only. Use LoAF for attribution where available and longtask as the portable baseline.

Why subtract 50 ms to get blocking time?

The first 50 ms of any task is considered within the responsiveness budget, so only the excess above 50 ms is the portion that can block an interaction. Summing that excess across a route gives Total Blocking Time, which tracks closely with INP.

Do I still need buffered: true if I observe early?

Yes. Even a script in the document head can miss long tasks that fired during parse, compile, and the first paints. buffered: true replays those earlier entries into your callback, which is exactly the high-cost window you most want to capture.