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 });
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.
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 Type | Chrome | Firefox | Safari | Use Case |
|---|---|---|---|---|
| navigation | 57+ | 58+ | 15+ | Page load timing, TTFB |
| resource | 57+ | 58+ | 15+ | Sub-resource loading |
| paint | 60+ | 84+ | 14.1+ | FCP measurement |
| largest-contentful-paint | 77+ | 122+ | No | LCP measurement |
| event | 96+ | No | No | INP measurement |
| layout-shift | 77+ | No | No | CLS measurement |
| longtask | 58+ | No | No | Main 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
asyncordefer. - 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
typemode (notentryTypes) withbuffered: trueto capture entries recorded before the observer was created. - Core Web Vitals are measured through specific entry types:
largest-contentful-paintfor LCP,eventfor INP, andlayout-shiftfor 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.