Synthetic tools tell you what a vendor tag can cost on one throttled run; only field instrumentation tells you what it does cost across every device, network, and interaction your real users bring. PerformanceObserver is the browser primitive that turns the performance timeline into a stream you subscribe to — each entry a timestamped, structured record of a resource fetch, a main-thread stall, a slow interaction, or a frame the browser could not paint on time. Wire it correctly and you get per-vendor attribution straight from production, with no polling, no wrapping of third-party APIs, and negligible overhead.

The difficulty is not calling the API — it is choosing the right entry types, tolerating the ones a browser has not shipped, capturing entries that fired before your observer registered, and shipping the data off the page reliably before the tab unloads. This guide builds a production instrumentation module that subscribes to the five entry types that matter for third-party cost — resource, longtask, event, long-animation-frame, and largest-contentful-paint — buffers what it sees, and flushes it with navigator.sendBeacon on visibilitychange. The result is the raw field signal that calibrates your performance budgets in Lighthouse CI and feeds RUM attributed to individual vendors.


PerformanceObserver subscription and beacon flush The performance timeline emits five entry types — resource, longtask, event, long-animation-frame, and largest-contentful-paint — into a single PerformanceObserver. The observer normalises each entry into a buffer, and a visibilitychange listener drains that buffer to a collector endpoint using navigator.sendBeacon. entryTypes resource longtask event long-animation-frame largest-contentful-paint Performance Observer buffered: true in-memory buffer normalised entries sendBeacon on visibilitychange collector /rum ingest flush POST

Prerequisites and When to Apply This Guide

Reach for PerformanceObserver instrumentation when you need field-level, per-vendor performance data that synthetic tools cannot give you. Apply it when:

  • You load third-party scripts (tag managers, analytics, chat widgets, ad tags, session-replay SDKs) whose real-world cost you cannot see from a Lighthouse run alone.
  • You have — or can stand up — a collector endpoint that accepts small JSON payloads over POST. A serverless function or a single route on your existing backend is enough.
  • You are prepared to sample. Sending an entry for every resource on every page view from every user produces a firehose; production instrumentation samples (for example, 10% of sessions) and caps payload size.

If your goal is specifically to name which vendor blocked the main thread rather than to build a full timeline pipeline, read the focused companion guide on capturing longtask attribution for third-party embeds, which drills into the attribution limits of longtask and the richer long-animation-frame alternative.

One dependency graph decision matters before you write code: instrumentation must run early, ideally as a small first-party inline script in <head>, so its observer is registered before third-party tags start executing. Combined with buffered: true (covered below) this captures entries that predate the observer, but registering early still reduces the window where a slow tag stalls the main thread before anyone is watching.

The PerformanceObserver Contract and the Entry Types That Matter

PerformanceObserver takes a callback and an observe(options) call. The callback receives a PerformanceObserverEntryList; you read entries with list.getEntries(). The observe options accept either a single type (which permits buffered: true) or an entryTypes array (which does not). This distinction is the single most common source of silently missing data, so the production pattern uses one observer per type.

Each entry type exposes a different slice of third-party cost:

  • resource — a PerformanceResourceTiming per fetched asset. Gives you name (the URL), initiatorType, transferSize (compressed bytes over the wire), encodedBodySize, duration, and the full connection breakdown (domainLookupStart, connectStart, responseEnd). Filtering name by vendor host is how you attribute bytes and network time to each third party.
  • longtask — a PerformanceLongTaskTiming for any task that occupied the main thread for 50 ms or more. Gives you duration and startTime, but its attribution only names the containing frame, never the script — the precise limitation the companion attribution guide resolves.
  • event — a PerformanceEventTiming for slow interactions, the raw material of INP. It only surfaces events whose duration meets durationThreshold (default 104 ms; you may lower it, but not below 16 ms). Gives you name (e.g. pointerdown), duration, processingStart, processingEnd, and startTime, letting you measure interaction latency that vendor input handlers inflate.
  • long-animation-frame (LoAF) — a PerformanceLongAnimationFrameTiming for any animation frame that took over 50 ms. This is the richest attribution the platform offers: its scripts[] array names each culprit with sourceURL, invoker, invokerType, forcedStyleAndLayoutDuration, and duration. LoAF is how you attribute main-thread blocking to a specific vendor URL.
  • largest-contentful-paint — a LargestContentfulPaint entry marking the LCP candidate. Its url and element reveal when a third-party image or embed is your LCP, and its renderTime/loadTime quantify how a slow vendor asset pushes LCP out.

buffered: true is essential. Passing it in the observe options tells the browser to replay entries of that type that were recorded before the observer existed — up to a per-type buffer limit. Without it, an observer registered even 200 ms into the page load misses every resource and long task that already fired.

Implementation: A Production Instrumentation Module

Step 1 — Guard Support and Register Observers Per Type

Feature-detect both PerformanceObserver and each entry type via PerformanceObserver.supportedEntryTypes. Registering an unsupported type throws, so guarding per type keeps one missing feature (LoAF is not yet in every browser) from breaking the whole module.

// rum-observer.js — load as an early first-party module in <head>.
const SUPPORTED = new Set(
  (PerformanceObserver.supportedEntryTypes ?? [])
);

// Bounded buffer so a long-lived tab cannot grow memory without limit.
const buffer = [];
const MAX_BUFFER = 200;

function record(record) {
  if (buffer.length >= MAX_BUFFER) return; // drop rather than leak
  buffer.push(record);
}

function observe(type, mapFn, extraOptions = {}) {
  if (!('PerformanceObserver' in window) || !SUPPORTED.has(type)) return;
  try {
    const po = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        const mapped = mapFn(entry);
        if (mapped) record(mapped);
      }
    });
    // buffered:true replays entries recorded before this observer existed.
    po.observe({ type, buffered: true, ...extraOptions });
  } catch (err) {
    // A single unsupported type must never break the others.
    if (window.console) console.debug('[rum] observe failed', type, err);
  }
}

Step 2 — Attribute resource Entries to Vendors

Map each resource entry to a compact record keyed by vendor host. Keep only cross-origin third-party requests to avoid drowning your own first-party assets in the payload.

const SELF_ORIGIN = location.origin;

// Only known third-party hosts you care about; extend as vendors change.
const VENDOR_HOSTS = [
  'googletagmanager.com',
  'google-analytics.com',
  'connect.facebook.net',
  'js.intercomcdn.com',
  'cdn.segment.com',
];

function vendorFor(url) {
  try {
    const host = new URL(url).hostname;
    return VENDOR_HOSTS.find((v) => host.endsWith(v)) ?? null;
  } catch {
    return null;
  }
}

observe('resource', (e) => {
  if (e.name.startsWith(SELF_ORIGIN)) return null; // first-party
  const vendor = vendorFor(e.name);
  if (!vendor) return null;
  return {
    t: 'resource',
    vendor,
    transferSize: e.transferSize,        // compressed bytes over the wire
    encodedBodySize: e.encodedBodySize,
    duration: Math.round(e.duration),
    // Connection cost isolates DNS/TLS from download time.
    dns: Math.round(e.domainLookupEnd - e.domainLookupStart),
    ttfb: Math.round(e.responseStart - e.requestStart),
  };
});

Step 3 — Capture longtask, event, LoAF, and LCP

The event observer needs an explicit durationThreshold; LoAF carries the vendor-naming scripts[] array; LCP records only the final candidate you care about at flush time.

// Long tasks: coarse main-thread stalls (attribution names only the frame).
observe('longtask', (e) => ({
  t: 'longtask',
  duration: Math.round(e.duration),
  startTime: Math.round(e.startTime),
  container: e.attribution?.[0]?.containerType ?? 'window',
}));

// Slow interactions (INP inputs). durationThreshold must live in observe().
observe(
  'event',
  (e) => ({
    t: 'event',
    name: e.name,
    duration: Math.round(e.duration),
    // Input delay = time from event to handler start.
    inputDelay: Math.round(e.processingStart - e.startTime),
  }),
  { durationThreshold: 40 } // catch interactions above 40 ms
);

// Long Animation Frames: richest attribution — names the vendor script.
observe('long-animation-frame', (e) => ({
  t: 'loaf',
  duration: Math.round(e.duration),
  blockingDuration: Math.round(e.blockingDuration),
  scripts: (e.scripts ?? []).map((s) => ({
    source: s.sourceURL,          // the actual vendor URL that blocked
    invoker: s.invoker,           // e.g. "IMG.onload" or a timer handler
    invokerType: s.invokerType,
    duration: Math.round(s.duration),
  })),
}));

// LCP: keep updating; the last entry before flush is the real LCP.
let lcp = null;
observe('largest-contentful-paint', (e) => {
  lcp = {
    t: 'lcp',
    renderTime: Math.round(e.renderTime || e.loadTime),
    url: e.url || null,           // set when LCP is a third-party image
    size: e.size,
  };
  return null; // don't buffer every candidate; flush the final one
});

Step 4 — Flush Reliably with sendBeacon on visibilitychange

The one moment you are guaranteed the page is ending is the visibilitychange to hiddenunload and beforeunload are unreliable on mobile and break the back/forward cache. navigator.sendBeacon queues the payload with the browser and returns immediately, surviving the teardown that would abort a fetch.

const ENDPOINT = '/rum/ingest';
let flushed = false;

function flush() {
  if (flushed) return;               // guard against double-fire
  const payload = { lcp, entries: buffer.splice(0) };
  if (!payload.lcp && payload.entries.length === 0) return;

  const body = JSON.stringify({
    url: location.pathname,
    ts: Date.now(),
    ...payload,
  });

  // sendBeacon survives page teardown; fetch(keepalive) is the fallback.
  const ok = navigator.sendBeacon?.(ENDPOINT, body);
  if (!ok) {
    fetch(ENDPOINT, { method: 'POST', body, keepalive: true }).catch(() => {});
  }
  flushed = true;
}

// hidden is the last reliable event on mobile; also cover pagehide.
addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});
addEventListener('pagehide', flush);

Set flushed = false and re-arm only if you intend to send incremental batches; for most sites a single end-of-session flush is correct and cheapest.

Verification Checklist

Interaction with RUM Attribution and Budget Enforcement

This module produces raw signal; two sibling practices consume it.

The entries you flush here are the input to attributing RUM data to third-party vendors. That guide covers the aggregation side — grouping resource and loaf records by vendor host on the collector, computing per-vendor percentiles, and correlating an interaction’s event entry with the LoAF that occurred in the same frame. Instrumentation without aggregation is just noise; aggregation without instrumentation has nothing to group. When you need to trace a specific INP regression back to one vendor, the deep dive on attributing INP regressions to a single vendor script joins the event and LoAF streams this module emits.

On the enforcement side, the field numbers this observer records are exactly what should set the thresholds you gate in Lighthouse CI performance budgets. A transferSize distribution captured from real users tells you where to set a transfer-size budget for third-party scripts so the ceiling reflects the field rather than one synthetic run. And because resource timings expose DNS and connection cost per vendor, they reveal when optimizing the network waterfall for external assets — preconnecting to a slow origin — would move real numbers.

Troubleshooting

Observer registers but the callback never fires

Symptom: No entries arrive even though the vendor script clearly loads.

Cause: You passed entryTypes: [...] and buffered: true in the same observe call. The buffered flag is only honoured with the single-type form; mixing them either throws or silently ignores the buffer, and some engines reject the whole call.

Fix: Use one observer per type with { type, buffered: true }, exactly as in Step 1. Never combine entryTypes with buffered.

observe throws The provided value 'long-animation-frame' is not a valid entry type

Symptom: A TypeError in the console and the module stops registering later observers.

Cause: The browser does not support that entry type, and an unguarded observe throws synchronously, aborting the rest of your setup.

Fix: Gate every observe on PerformanceObserver.supportedEntryTypes.includes(type) and wrap the call in try/catch, as the observe() helper does. LoAF in particular is Chromium-only at the time of writing.

The beacon never arrives at the collector

Symptom: visibilitychange fires but no request appears at the endpoint.

Cause: Either the payload exceeded the sendBeacon size limit (roughly 64 KB), or you attached the flush to unload, which is ignored under back/forward cache and unreliable on mobile Safari.

Fix: Cap the buffer (as in Step 1), sample sessions to keep payloads small, and flush on visibilitychange/pagehide rather than unload. Fall back to fetch(url, { keepalive: true }) when sendBeacon returns false.

event entries never appear despite slow clicks

Symptom: resource and longtask records flow, but the event stream is empty.

Cause: The default durationThreshold of 104 ms filtered out your interactions, or you set durationThreshold in the observer constructor instead of in observe().

Fix: Pass durationThreshold inside the observe({ type: 'event', durationThreshold, buffered: true }) options. Remember it cannot go below 16 ms; values under that are clamped.

Frequently Asked Questions

Why one observer per entry type instead of a single observer with entryTypes?

Because buffered: true is only accepted with the single-type form of observe. The entryTypes array form cannot replay entries recorded before the observer existed, so you would miss every resource and long task that fired during early page load. One observer per type also isolates failures: if long-animation-frame is unsupported, guarding and catching per type keeps resource, longtask, and event working. The small extra object cost is negligible.

Does running a PerformanceObserver measurably slow the page?

The observer itself is cheap — the browser is already recording these entries for its own internal timeline; you are subscribing to an existing stream, not creating new work. The costs to manage are payload size and callback work. Keep the callback trivial (map and push, no synchronous network or layout reads), bound the buffer, and sample sessions. Done this way the overhead is well under a millisecond of main-thread time per page.

What is the difference between longtask and long-animation-frame for attribution?

longtask tells you that the main thread was blocked for 50 ms or more, but its attribution only names the containing frame — never the script. long-animation-frame (LoAF) exposes a scripts[] array where each entry carries sourceURL, invoker, and duration, so you can attribute a blocking frame to a specific vendor URL. Prefer LoAF where it is supported; the companion guide on longtask attribution covers the migration and the iframe fallback for browsers without LoAF.

Why flush on visibilitychange rather than beforeunload?

beforeunload and unload are not fired reliably when a mobile user backgrounds the tab or when the page enters the back/forward cache, and registering an unload handler disqualifies the page from that cache entirely. The visibilitychange transition to hidden is the last event you are reliably given on every platform, so it is the correct flush trigger. Pair it with pagehide as a belt-and-braces fallback, and use navigator.sendBeacon so the request survives teardown.


Up: Monitoring & Measuring Third-Party Performance