PerformanceObserver API: Browser Performance Data Collection

The PerformanceObserver API is the foundation of every modern Real User Monitoring implementation. It provides a unified, asynchronous interface for subscribing to performance entries as they are recorded by the browser — paint timings, resource loads, long tasks, layout shifts, and user interactions. Before PerformanceObserver existed, developers polled performance.getEntries() on timers, which was both inefficient and unreliable. The observer pattern ensures you receive entries exactly once, as they become available, with no polling overhead.

Understanding PerformanceObserver is essential because every Core Web Vital — LCP, INP, and CLS — is measured through this API. The web-vitals library, Google's official CWV measurement library, is built entirely on PerformanceObserver. If you want to build custom performance instrumentation, collect additional metrics beyond CWV, or understand how your RUM tool works under the hood, the PerformanceObserver API is where it all starts.

The Observer Pattern

PerformanceObserver follows the standard observer pattern: you create an observer instance with a callback function, then tell it which entry types to observe. The callback fires whenever new entries of the subscribed types are recorded by the browser.

// Basic PerformanceObserver setup
const observer = new PerformanceObserver((list, observer) => {
  const entries = list.getEntries();
  for (const entry of entries) {
    console.log(`[${entry.entryType}] ${entry.name}: ${entry.startTime.toFixed(1)}ms`);
  }
});

// Subscribe to multiple entry types
observer.observe({
  entryTypes: ['navigation', 'resource', 'paint', 'longtask']
});

There are two observation modes: entryTypes (multi-type, legacy) and type (single-type, modern). The type mode supports the critical buffered option that replays entries recorded before the observer was created.

// Modern single-type observation with buffered replay
const lcpObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // entry is a LargestContentfulPaint entry
    console.log('LCP candidate:', entry.startTime, entry.element?.tagName);
  }
});

// buffered: true replays entries from before observer creation
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
Critical distinction: The entryTypes mode does not support buffered: true. If your RUM script loads asynchronously (as it should, to avoid blocking page load), you must use the type mode with buffered: true to capture entries that fired before the script loaded.

Performance Entry Types

The browser records different types of performance entries, each representing a different aspect of the page lifecycle. Understanding what each entry type contains is essential for building accurate performance measurements.

Page Lifecycle Timeline → navigation resource (scripts, CSS, images, fonts, XHR) paint FP, FCP largest-contentful-paint longtask (>50ms main thread blocks) event (clicks, keypresses, taps → INP) layout-shift events (CLS) 0ms FCP LCP Interactive

Navigation Timing

The navigation entry type provides a single, comprehensive entry that covers the entire page load lifecycle. It extends PerformanceResourceTiming with additional properties specific to document navigation: unloadEventStart, domInteractive, domContentLoadedEventStart, loadEventStart, and more.

// Extracting key timings from Navigation Timing
const observer = new PerformanceObserver((list) => {
  const nav = list.getEntries()[0]; // Only one navigation entry per page

  const timings = {
    dns: nav.domainLookupEnd - nav.domainLookupStart,
    tcp: nav.connectEnd - nav.connectStart,
    tls: nav.secureConnectionStart > 0
      ? nav.connectEnd - nav.secureConnectionStart : 0,
    ttfb: nav.responseStart - nav.requestStart,
    download: nav.responseEnd - nav.responseStart,
    domParsing: nav.domInteractive - nav.responseEnd,
    domContentLoaded: nav.domContentLoadedEventEnd - nav.domContentLoadedEventStart,
    fullLoad: nav.loadEventEnd - nav.startTime,
    transferSize: nav.transferSize,
    decodedSize: nav.decodedBodySize
  };

  sendToAnalytics('navigation', timings);
});
observer.observe({ type: 'navigation', buffered: true });

The Time to First Byte (TTFB) calculation — responseStart - requestStart — is one of the most frequently used values from Navigation Timing. TTFB establishes the performance floor for your page: no visual content can appear before the first byte arrives. A high TTFB in RUM data, particularly when server-side monitoring shows fast response times, typically indicates network latency between the user and your origin server.

Resource Timing

Every sub-resource fetched during the page load — scripts, stylesheets, images, fonts, fetch/XHR requests — generates a resource entry. Resource Timing entries contain the same connection and transfer timing properties as Navigation Timing, plus the resource URL, initiator type, and transfer size.

// Monitoring slow resources
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    const duration = entry.responseEnd - entry.startTime;
    if (duration > 1000) { // Resources taking more than 1 second
      console.warn('Slow resource:', {
        url: entry.name,
        type: entry.initiatorType, // 'script', 'css', 'img', 'fetch'
        duration: Math.round(duration),
        transferSize: entry.transferSize,
        cached: entry.transferSize === 0
      });
    }
  }
});
observer.observe({ type: 'resource', buffered: true });

A resource with transferSize === 0 was served from the browser cache. This distinction lets you measure cache hit ratios in the field — not just at the CDN edge, but at the actual browser level. High cache hit ratios for static assets indicate that your caching headers (Cache-Control, ETag) are configured correctly and users are returning to your site frequently enough to benefit from cached resources.

Largest Contentful Paint

LCP entries represent candidates for the largest visible content element. The browser emits multiple LCP entries as larger elements render — an initial text block, then a hero image, then an even larger banner. The last entry before user interaction (click, tap, scroll, keypress) is the final LCP value.

// Collecting final LCP value
let finalLCP = 0;
const lcpObserver = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];
  finalLCP = lastEntry.startTime;

  // Additional diagnostics
  console.log('LCP element:', lastEntry.element?.tagName);
  console.log('LCP URL:', lastEntry.url); // For images
  console.log('LCP size:', lastEntry.size);
  console.log('LCP render time:', lastEntry.renderTime);
  console.log('LCP load time:', lastEntry.loadTime);
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });

// Report final value when page becomes hidden
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    sendToAnalytics('lcp', finalLCP);
    lcpObserver.disconnect();
  }
});

The renderTime and loadTime properties on LCP entries are diagnostic gold. If loadTime is high but renderTime is close to loadTime, the bottleneck is downloading the LCP element (usually an image). If loadTime is low but renderTime is much later, the bottleneck is render-blocking resources (CSS, synchronous scripts) that delay painting even after the LCP resource has been downloaded.

Event Timing and INP

Event Timing entries capture the latency of user interactions — clicks, taps, and key presses. Each entry records the startTime (when the input event arrived), processingStart (when the event handler began executing), processingEnd (when the handler finished), and the implied presentation delay (time from handler completion to the next paint). Interaction to Next Paint (INP) is the worst interaction duration across all entries during the page session.

// Measuring INP with Event Timing
let worstINP = 0;
const eventObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Only process entries with an interactionId (user interactions)
    if (!entry.interactionId) continue;

    const inputDelay = entry.processingStart - entry.startTime;
    const processingTime = entry.processingEnd - entry.processingStart;
    const presentationDelay = entry.duration - (entry.processingEnd - entry.startTime);
    const totalDuration = entry.duration;

    if (totalDuration > worstINP) {
      worstINP = totalDuration;
    }

    // Detailed breakdown for slow interactions
    if (totalDuration > 200) {
      console.warn('Slow interaction:', {
        type: entry.name,
        target: entry.target?.tagName,
        inputDelay: Math.round(inputDelay),
        processingTime: Math.round(processingTime),
        presentationDelay: Math.round(presentationDelay),
        total: Math.round(totalDuration)
      });
    }
  }
});
eventObserver.observe({ type: 'event', buffered: true, durationThreshold: 16 });

The durationThreshold: 16 option filters out interactions shorter than 16ms (one frame at 60fps), reducing noise from trivial events. For INP measurement, this threshold avoids recording thousands of fast interactions that would never be the INP candidate.

Layout Instability (CLS)

Layout shift entries record whenever visible elements move without user input. Each entry has a value representing the shift severity (calculated from the fraction of the viewport affected multiplied by the distance moved) and a hadRecentInput boolean that indicates whether the shift followed a user interaction within 500ms.

// CLS measurement with session windowing
let clsValue = 0;
let clsEntries = [];
let sessionValue = 0;
let sessionEntries = [];
const clsObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Ignore shifts caused by user input
    if (entry.hadRecentInput) continue;

    // Session window: gap of 1s max, window of 5s max
    const lastEntry = sessionEntries[sessionEntries.length - 1];
    if (lastEntry &&
        entry.startTime - lastEntry.startTime < 1000 &&
        entry.startTime - sessionEntries[0].startTime < 5000) {
      sessionValue += entry.value;
      sessionEntries.push(entry);
    } else {
      sessionValue = entry.value;
      sessionEntries = [entry];
    }

    if (sessionValue > clsValue) {
      clsValue = sessionValue;
      clsEntries = [...sessionEntries];
    }
  }
});
clsObserver.observe({ type: 'layout-shift', buffered: true });

CLS uses a session windowing algorithm: shifts are grouped into sessions where consecutive shifts are no more than 1 second apart and the entire session spans no more than 5 seconds. The CLS value is the largest session window sum. This windowing prevents long-lived pages (dashboards, email clients) from accumulating unbounded CLS scores from minor, infrequent shifts over hours of use.

Long Tasks

Any JavaScript execution that blocks the main thread for more than 50ms generates a longtask entry. Long tasks directly impact INP because a user interaction that arrives during a long task must wait for the task to complete before the event handler can begin processing (this waiting time is the "input delay" component of INP).

// Monitoring long tasks and their attribution
const longTaskObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    const attribution = entry.attribution?.[0];
    console.log('Long task:', {
      duration: Math.round(entry.duration),
      containerType: attribution?.containerType,
      containerName: attribution?.containerName,
      containerSrc: attribution?.containerSrc
    });
  }
});
longTaskObserver.observe({ type: 'longtask' });

The attribution property on long task entries reveals the source of the blocking work. A containerType of "iframe" with a containerSrc pointing to a third-party domain immediately identifies an ad iframe as the culprit. A containerType of "window" indicates first-party code. This attribution data is invaluable for prioritizing performance work — knowing that 60% of long tasks come from a single third-party script provides a clear optimization target.

Browser Support and Feature Detection

PerformanceObserver is well-supported across modern browsers, but individual entry types vary. Feature detection is straightforward:

// Check if a specific entry type is supported
function isEntryTypeSupported(type) {
  try {
    return PerformanceObserver.supportedEntryTypes?.includes(type) ?? false;
  } catch (e) {
    return false;
  }
}

// Conditionally observe based on support
if (isEntryTypeSupported('largest-contentful-paint')) {
  observeLCP();
}
if (isEntryTypeSupported('event')) {
  observeINP();
}
if (isEntryTypeSupported('layout-shift')) {
  observeCLS();
}
Entry TypeChromeFirefoxSafariUse Case
navigation57+58+15+Page load timing, TTFB
resource57+58+15+Sub-resource loading
paint60+84+14.1+FCP measurement
largest-contentful-paint77+122+NoLCP measurement
event96+NoNoINP measurement
layout-shift77+NoNoCLS measurement
longtask58+NoNoMain thread blocking

Safari's lack of support for LCP, layout-shift, and event entry types means CWV measurement in Safari relies entirely on Chrome User Experience Report (CrUX) data from Chrome visitors to the same pages. This limitation is important for sites with significant Safari traffic — your RUM data will undercount Safari's contribution to CWV unless you implement polyfill approximations.

Building a Complete RUM Collector

A production-quality RUM collector combines all the individual observers into a unified data pipeline. The key design principles are: observe early, buffer locally, transmit late, and handle edge cases.

// Production RUM collector skeleton
class RUMCollector {
  constructor(endpoint) {
    this.endpoint = endpoint;
    this.metrics = {};
    this.observers = [];
    this.init();
  }

  init() {
    this.observeNavigation();
    this.observeLCP();
    this.observeINP();
    this.observeCLS();
    this.observeResources();
    this.observeLongTasks();

    // Transmit on page hide
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') this.flush();
    });
  }

  record(name, value, metadata = {}) {
    this.metrics[name] = { value, timestamp: performance.now(), ...metadata };
  }

  flush() {
    if (Object.keys(this.metrics).length === 0) return;
    const payload = {
      url: location.href,
      userAgent: navigator.userAgent,
      connection: navigator.connection?.effectiveType,
      viewport: `${innerWidth}x${innerHeight}`,
      metrics: { ...this.metrics }
    };
    navigator.sendBeacon(this.endpoint, JSON.stringify(payload));
    this.metrics = {};
  }

  // ... individual observe methods as shown above
}

Performance Impact of PerformanceObserver

A common concern is whether the RUM collector itself degrades performance. The overhead of PerformanceObserver is minimal — the browser records performance entries regardless of whether an observer is registered. The observer merely provides access to entries the browser has already computed. The primary costs are:

  • Script download and parse: Keep the collector under 3 KB gzipped. Load it asynchronously with async or defer.
  • Callback execution: Observer callbacks run on the main thread. Keep them short — extract data, store it, return. Do not perform heavy computation in callbacks.
  • Beacon transmission: sendBeacon() is asynchronous and does not block the main thread. Payload size should be under 64 KB (the browser may reject larger beacons).

In practice, a well-implemented RUM collector adds less than 1ms of main thread blocking — unmeasurable in the context of page loads that typically take hundreds of milliseconds to seconds. The performance insight gained far outweighs the negligible cost of collection.

Key Takeaways

  • PerformanceObserver provides asynchronous, callback-driven access to browser performance entries — replacing the old polling approach with an efficient observer pattern.
  • Use the type mode (not entryTypes) with buffered: true to capture entries recorded before the observer was created.
  • Core Web Vitals are measured through specific entry types: largest-contentful-paint for LCP, event for INP, and layout-shift for CLS.
  • Long task attribution identifies the source of main thread blocking — first-party code versus third-party scripts and iframes.
  • Safari does not support LCP, layout-shift, or event entry types, creating a measurement gap for sites with significant Safari traffic.
  • The overhead of a well-implemented PerformanceObserver-based collector is negligible — under 1ms of main thread time per page load.