Your longtask observer is firing constantly — long main-thread stalls, plainly caused by an embedded map, a chat widget, or an ad tag — but every entry’s attribution says the same useless thing, and you cannot tell which embed to blame.

Triage: Confirm the Attribution Is Empty

Before changing anything, verify that the problem is longtask’s attribution model and not a bug in your observer. Reproduce the empty attribution directly in the console.

  1. Open DevTools on a page with a heavy third-party embed and paste an observer into the Console:

    new PerformanceObserver((list) => {
      for (const e of list.getEntries()) {
        console.log(e.duration.toFixed(0) + 'ms', e.attribution);
      }
    }).observe({ type: 'longtask', buffered: true });
  2. Interact with the embed (scroll a map, open a chat panel) to provoke long tasks, and read the logged attribution array.

  3. Expand the first TaskAttributionTiming object. Note that containerType is iframe/embed/window, containerName and containerId are the host element’s name and id — and there is no sourceURL, no script name, no vendor URL anywhere on the entry.

  4. Confirm the same task, viewed in the Performance panel (record, then look at the flame chart under the long yellow task), does show the vendor script on the stack — proving the information exists but is not exposed on the longtask entry.

Root Cause: TaskAttributionTiming Only Names the Containing Frame

The Long Tasks API deliberately exposes almost nothing about what ran. Each PerformanceLongTaskTiming entry carries an attribution array of TaskAttributionTiming objects, and by specification those objects only describe the containing frame of the culprit — containerType, containerSrc, containerId, and containerName. They never name the script, the function, or the vendor URL, because the Long Tasks API predates the browser plumbing needed to attribute work to a source safely across origins.

The consequence for third-party embeds is specific. If a vendor runs inside its own cross-origin <iframe>, the best you get is containerType: "iframe" and the containerSrc/containerId of that frame — usable only if you gave the frame a recognisable id. If the vendor injects a script that runs in the top frame (the common case for tag-manager-loaded analytics and pixels), containerType is "window" and you get nothing that distinguishes one top-frame vendor from another. Every top-frame vendor’s long task looks identical.

The reason the API withholds script identity is cross-origin isolation. Naming the function or URL that ran inside a task can leak information across the origin boundary — a timing side channel — so the original Long Tasks specification chose to expose only the coarse container. That container is enough to answer “is the main thread janky?” but not “which vendor is responsible?”, which is precisely the question you need answered when three or four third parties share the top frame. containerSrc looks promising until you realise it is the frame’s src, populated only for iframed content, and empty for the top frame where most tag-managed vendors execute.

There is a second, subtler trap: a single long task can bundle work from several sources. The browser coalesces contiguous main-thread work into one task, so an entry reporting 280ms might be 40 ms of your own code, 190 ms of a vendor, and 50 ms of a second vendor — yet attribution still collapses all of it to one container. Even if longtask did name scripts, one entry could not fairly credit one vendor. You need an entry type that itemises the scripts within a blocking window.

This is a hard limit of the entry type, not a configuration you can fix. The PerformanceObserver setup itself — support-gating, buffered: true, and beacon flushing — is covered by the governing guide on instrumenting third-party scripts with PerformanceObserver; this page is only about escaping longtask’s attribution gap. The escape is to observe a newer entry type that was designed to name scripts.

Resolution: Switch to long-animation-frame and Read scripts[]

The Long Animation Frames API (LoAF) records any animation frame that took 50 ms or more and, critically, exposes a scripts[] array in which each PerformanceScriptTiming names its source: sourceURL, invoker, invokerType, and per-script duration. That sourceURL is the vendor attribution longtask refuses to give you. LoAF sidesteps both problems above at once — it is safe to expose because it reports at the frame level rather than mid-task, and it itemises every script that ran in the frame instead of collapsing them to one container. Replace the longtask observer with a LoAF observer and attribute each blocking frame to the heaviest script within it.

The invoker and invokerType fields are as valuable as sourceURL for diagnosis. invokerType tells you how the vendor code was entered — user-callback for an event listener, timer-install/timer-fire for a setTimeout, resource-load for an onload handler — which distinguishes a vendor that blocks on a click from one that self-schedules background work. A record showing sourceURL: maps.googleapis.com, invokerType: user-callback, blockingDuration: 210 is a complete, actionable diagnosis: the maps embed’s click handler blocked interaction for 210 ms.

// Prefer LoAF: it names the vendor script that longtask hides.
const VENDOR_HOSTS = [
  'googletagmanager.com',
  'connect.facebook.net',
  'js.intercomcdn.com',
  'maps.googleapis.com',
];

function vendorFor(url) {
  try {
    const host = new URL(url, location.href).hostname;
    return VENDOR_HOSTS.find((v) => host.endsWith(v)) ?? 'first-party-or-unknown';
  } catch {
    return 'inline-or-eval';
  }
}

if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
  new PerformanceObserver((list) => {
    for (const frame of list.getEntries()) {
      // blockingDuration is the part of the frame that blocked interaction.
      if (frame.blockingDuration < 50) continue;

      // Attribute the frame to its single heaviest script.
      const scripts = frame.scripts ?? [];
      const worst = scripts.reduce(
        (max, s) => (s.duration > (max?.duration ?? 0) ? s : max),
        null
      );

      report({
        t: 'loaf',
        frameDuration: Math.round(frame.duration),
        blockingDuration: Math.round(frame.blockingDuration),
        // The vendor the longtask API could never name:
        vendor: worst ? vendorFor(worst.sourceURL) : 'unknown',
        sourceURL: worst?.sourceURL ?? null,
        invoker: worst?.invoker ?? null,        // e.g. "TIMER" or "IMG.onload"
        invokerType: worst?.invokerType ?? null,
        scriptDuration: worst ? Math.round(worst.duration) : 0,
      });
    }
  }).observe({ type: 'long-animation-frame', buffered: true });
}

function report(record) {
  // Buffer + sendBeacon flush lives in the shared instrumentation module.
  (window.__rumBuffer ??= []).push(record);
}

For browsers without LoAF (non-Chromium engines at the time of writing), fall back to per-iframe frame correlation: give each third-party iframe a stable, recognisable id, keep the longtask observer, and read containerId from its attribution. This only attributes iframed embeds — top-frame vendors remain anonymous under longtask — but it recovers the common map/chat/video embed case.

// Fallback for engines without LoAF: attribute only iframed embeds.
// Requires <iframe id="embed-<vendor>"> naming you control.
if (!PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) {
  new PerformanceObserver((list) => {
    for (const task of list.getEntries()) {
      const attr = task.attribution?.[0];
      if (attr?.containerType !== 'iframe') continue; // top-frame = unattributable
      report({
        t: 'longtask',
        duration: Math.round(task.duration),
        // Only useful because we control the iframe id naming scheme.
        frameId: attr.containerId || attr.containerName || 'unnamed-iframe',
        frameSrc: attr.containerSrc || null,
      });
    }
  }).observe({ type: 'longtask', buffered: true });
}

Use LoAF as the primary path and treat the longtask fallback as a degraded mode that only names iframed vendors — never as the source of truth. Because the two observers cover disjoint browser populations, run whichever the engine supports rather than both, and tag each record with its t type on the collector so your aggregation query never mixes a LoAF blockingDuration with a longtask duration as if they were the same metric. When you later join this stream to interaction data, the LoAF records are what let you tie a slow click to a named vendor — the same correlation the guide on attributing INP regressions to a single vendor script relies on.

Verification

Provoke a known-heavy embed and confirm the LoAF record names its vendor host. In DevTools Console with the LoAF observer running, interact with the embed, then inspect the buffered records:

console.table(
  window.__rumBuffer
    .filter((r) => r.t === 'loaf')
    .map(({ vendor, sourceURL, blockingDuration }) => ({ vendor, sourceURL, blockingDuration }))
);

The fix is confirmed when at least one row shows a vendor matching a known third-party host and a non-null sourceURL pointing at that vendor’s script — the exact attribution the longtask entry left blank. Cross-check by disabling the embed and confirming the corresponding rows disappear.

Common Pitfalls

  • Treating LoAF duration as blocking time. A frame’s duration includes rendering the browser did anyway; gate and rank on blockingDuration, the portion that actually delayed interaction, or you will over-report vendors that ran during idle frames.
  • Summing every script in the frame instead of attributing to the culprit. A single long frame can contain first-party and vendor scripts. Attribute to the heaviest PerformanceScriptTiming (or split proportionally by duration); crediting the whole frame to one vendor inflates its cost.
  • Relying on the longtask fallback for top-frame vendors. Tag-manager-loaded pixels and analytics run in the top frame, where containerType is "window" and no id exists. If those are your heavy vendors, the fallback cannot name them — LoAF is the only path, so record “unattributable” rather than guessing.
  • Forgetting to sample and cap. LoAF frames are common on interaction-heavy pages, so an unsampled observer can emit dozens of records per session. Reuse the bounded buffer and session sampling from the shared instrumentation module; a busy embed can otherwise dominate your beacon payload and crowd out the resource and event records you also need.

Up: Instrumenting Third-Party Scripts with PerformanceObserver