PerformanceObserver API Patterns

PerformanceObserver is the browser’s push-based interface for reading performance entries as the engine emits them, instead of polling performance.getEntries() on a timer. It is the right tool for capturing long tasks, layout shifts, paint timings, and interaction latency without holding the main thread or missing entries that arrived before your code ran. This is part of Rendering Performance Metrics and Tooling, and it underpins how the field measurements in Core Web Vitals Measurement are collected in production.

How PerformanceObserver buffers entries and delivers them to a callback The rendering engine writes performance entries into a buffer. The observer drains the buffer and invokes the registered callback with a list of entries, off the critical path. Rendering engine Observer Emit entry Entry buffer observe(types) callback(list) report / beacon buffered:true replays past entries

Why Push Beats Polling

performance.getEntries() returns a snapshot of the performance timeline at the moment you call it. To use it as a monitor you must call it on an interval, diff against the last snapshot, and hope your timer fires often enough to catch every entry before the buffer is trimmed. That polling loop itself runs on the main thread and competes for the same 16.6ms frame budget you are trying to measure.

PerformanceObserver inverts this. You register interest in a set of entry types once, and the engine invokes your callback whenever new entries of those types are recorded — typically batched and delivered during an idle moment so the callback does not extend a frame. Some entry types (notably largest-contentful-paint and layout-shift) are observer-only: they are never exposed through getEntries() at all, so polling cannot see them.

// ❌ Polling: misses entries between ticks, runs work every interval
let seen = 0
setInterval(() => {
  const entries = performance.getEntriesByType('longtask') // snapshot only
  for (let i = seen; i < entries.length; i++) report(entries[i])
  seen = entries.length
}, 1000) // 1s of long tasks can be silently dropped if the buffer fills

// âś… Push: the engine hands you every entry as it is recorded
const obs = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) report(entry) // delivered off the frame's critical path
})
obs.observe({ type: 'longtask', buffered: true }) // buffered replays pre-registration entries

Entry Types Worth Observing

Each entryType maps to a distinct rendering or interaction signal. The pipeline-relevant ones:

entryType what it captures target
longtask main-thread blocks ≥ 50ms 0 per interaction window
long-animation-frame frames whose render took too long, with script attribution render < 16.6ms
event per-interaction input latency (feeds INP) INP < 200ms
layout-shift unexpected movement of visible content (feeds CLS) CLS < 0.1
largest-contentful-paint render time of the largest viewport element LCP < 2.5s
paint First Paint and First Contentful Paint marks FCP < 1.8s
element render timing of elements you tag with elementtiming per-element budget

The two most useful for diagnosing dropped frames are longtask and long-animation-frame. The first tells you that the main thread stalled; the second tells you which script stalled it and how long it blocked rendering. See Observing Long Tasks with PerformanceObserver and Tracking Long Animation Frames for the per-type repros.

buffered: true and the Registration Race

The hardest bug with observers is registering too late. The browser records LCP, FCP, and early long tasks during the first paint — often before your analytics bundle has even parsed. Without buffered: true, those entries are gone by the time you call observe().

// âś… Replay entries recorded before this observer existed
const lcpObs = new PerformanceObserver((list) => {
  const entries = list.getEntries()
  const last = entries[entries.length - 1] // LCP is the final entry, not the first
  reportLCP(last.startTime)
})
lcpObs.observe({ type: 'largest-contentful-paint', buffered: true })

buffered: true instructs the engine to immediately deliver any matching entries already sitting in the performance buffer, then continue streaming new ones. This is the single most important flag for field measurement: it makes the observer’s view independent of when your script happened to run.

Note the shape difference: observe({ type: '...', buffered: true }) observes exactly one type and supports buffered. The plural observe({ entryTypes: ['a', 'b'] }) observes several at once but silently ignores buffered and several type-specific options. Prefer one observer per type for anything you care about buffering.

observe vs takeRecords

Calling observe() starts delivery; the callback fires asynchronously. Sometimes you need the entries right now — for example, in a visibilitychange handler when the page is being unloaded and the next async callback may never run.

const obs = new PerformanceObserver((list) => queue.push(...list.getEntries()))
obs.observe({ type: 'layout-shift', buffered: true })

addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    // Drain entries the engine has buffered but not yet delivered to the callback
    for (const entry of obs.takeRecords()) queue.push(entry)
    navigator.sendBeacon('/cls', JSON.stringify(summarize(queue))) // flush before unload
  }
}, { once: true })

takeRecords() synchronously returns and clears the observer’s pending queue without waiting for the next callback tick. Pairing it with sendBeacon in a visibilitychange handler is the standard pattern for not losing the final layout shift or interaction when a user navigates away — the same flush discipline used when debugging CLS with the Layout Instability API.

A Trace of Delivery Timing

What the timeline looks like when an observer is registered with buffered: true mid-load:

[Page load timeline — observer registered at 1.4s]
  0.0s  navigationStart
  0.9s  Paint: first-contentful-paint .......... buffered
  1.2s  largest-contentful-paint (candidate) ... buffered
  1.4s  obs.observe({ buffered:true }) called
  1.4s  → callback fires with 2 replayed entries  (FCP, LCP candidate)
  3.1s  longtask 72ms ........................... live  → callback
  3.1s  long-animation-frame 81ms (blocking 64ms) live  → callback
        Frame budget 16.6ms exceeded by the LoAF — INP at risk

Without buffered: true, the 0.9s and 1.2s rows are lost and only the live 3.1s entries arrive.

Validating the Observer Itself

A monitor that drops data is worse than none. Confirm coverage with these checks:

metric target how measured
Buffered entries on registration > 0 for paint/lcp log list.getEntries().length in first callback
Callback self-cost < 2ms wrap callback body in performance.now() deltas
LCP captured exactly 1 final value last largest-contentful-paint entry before unload
CLS flushed on hide 1 beacon per session network panel filter on visibilitychange

If the callback itself shows up as a longtask, you are doing too much synchronous work inside it — batch entries into a queue and process them in requestIdleCallback. With the observer wired correctly, the long-task and LoAF streams it produces become the raw input for the per-type debugging guides in Observing Long Tasks with PerformanceObserver and Tracking Long Animation Frames.