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 drops entries between ticks; push delivers each one A setInterval poller only reads entries present at each tick and loses entries when the buffer is trimmed, while a PerformanceObserver delivers every recorded entry to its callback. Polling β€” setInterval snapshot tick tick tick dropped dropped Push β€” PerformanceObserver callback callback(list) β€” every entry delivered
// ❌ 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.

Each entryType maps to a rendering or interaction signal The observable entry types on the left each feed a specific rendering signal or Core Web Vital on the right. entryType signal it feeds longtask main-thread stall long-animation-frame blocking script attribution event INP β€” interaction latency layout-shift CLS largest-contentful-paint LCP paint FCP / First Paint

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.

buffered:true replays entries recorded before registration FCP and the LCP candidate are recorded before observe runs; buffered true replays them to the callback the moment the observer registers at 1.4 seconds. observe({ buffered: true }) at 1.4s FCP 0.9s LCP cand 1.2s callback: 2 replayed buffered replays past entries 0.0s nav 0.9s 1.2s 1.4s observe 3.1s longtask (live)

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.

Async callback delivery versus synchronous takeRecords drain The observe callback fires on a later idle tick that may not run before unload, while takeRecords drains pending entries synchronously so a beacon can flush before the page is gone. observe() β€” async delivery takeRecords() β€” synchronous layout-shift recorded engine pending queue callback β€” next idle tick visibilitychange: hidden takeRecords() drains now sendBeacon flush may not fire before unload runs before the page unloads

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. The two live entries at 3.1s describe the same stall from different angles β€” the long task reports 72ms of blocked main thread, the long animation frame reports an 81ms render with 64ms of it attributable to blocking script, both far past the 16.6ms frame budget:

Observed durations at 3.1s against the frame budget The 16.6ms frame budget bar is dwarfed by the 72ms long task and the 81ms long animation frame, of which 64ms is blocking script. Duration vs 16.6ms frame budget 16.6ms budget frame budget 16.6ms longtask 72ms main-thread block LoAF 64ms blocking 81ms

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.

Four checks that qualify an observer as trustworthy field data Buffered entries, callback self-cost, a single LCP value, and a flushed CLS beacon all feed into trustworthy field data. Observer health checks buffered > 0 on paint, lcp callback < 2ms self-cost LCP exactly 1 final CLS beacon 1 per session trustworthy field data A callback that shows up as a longtask means too much sync work β€” defer with requestIdleCallback.

One API, Many Entry Types

PerformanceObserver is the single API through which almost every rendering metric is measured in the field, and its power is that one observer pattern covers many entry types. You create an observer with a callback and call observe({ type, buffered: true }), where buffered replays entries that occurred before the observer was created β€” essential for metrics like LCP that happen early in the load. The entry types that matter for rendering are largest-contentful-paint for LCP, layout-shift for CLS, event for INP (via Event Timing), longtask for main-thread blocking, long-animation-frame for slow frames (LoAF), and paint for First Contentful Paint. Learning the one pattern unlocks all of them, which is why this API sits at the centre of field measurement.

The design point worth internalising is that these observers are cheap and passive β€” they report what the browser already measured, without forcing any work of their own β€” so instrumenting all of them in production is low-risk. The cost is in what you do with the entries: sending every one to an analytics endpoint is wasteful, so the practical pattern is to aggregate on the client (keep a running p75, accumulate CLS into session windows) and report summaries. Each specific metric has its own extraction logic, detailed in observing long tasks with PerformanceObserver and tracking long animation frames.

// The one pattern, applied to three rendering metrics.
const observe = (type, handler) =>
  new PerformanceObserver((list) => list.getEntries().forEach(handler))
    .observe({ type, buffered: true })

observe('largest-contentful-paint', (e) => reportLCP(e.startTime, e.element))
observe('layout-shift', (e) => { if (!e.hadRecentInput) addCLS(e.value, e.sources) })
observe('longtask', (e) => reportLongTask(e.duration, e.attribution))

Aggregating for the Field

Raw observer entries are not the metric β€” the metric is a statistic computed over many entries, and computing it correctly is where field measurement succeeds or fails. LCP is the last largest-contentful-paint entry before the first interaction, so you keep the most recent and finalise on input. CLS is the sum of layout-shift values without recent input, grouped into session windows and reported as the worst window. INP is a high percentile of interaction latencies, not the single worst, so you keep a bounded list and take the p98-ish value near the end. Getting these aggregations right is what makes a field number trustworthy; getting them wrong produces numbers that disagree with Google’s own field data and send you chasing phantoms.

The final step is reporting the right statistic. Because rendering performance is heavy-tailed, you report percentiles β€” the p75 Google grades against β€” not averages, and you segment by route and device class so a regression on low-end hardware is not averaged away by fast desktops. Sending aggregated summaries rather than raw entries keeps the telemetry cheap, and keying them by route with the per-entry attribution (the LCP element, the CLS sources, the INP phase) keeps them actionable. That combination β€” the observer API to collect, correct aggregation to compute, and attribution to explain β€” is the whole field-measurement stack, and it feeds directly into the budgets enforced in lab tooling and CI.

Why buffered Matters

A subtlety that trips up first-time users of PerformanceObserver is that some of the most important entries occur before your observer code runs. LCP candidates, First Contentful Paint, and early layout shifts all happen during the initial load, often before your analytics script has even parsed. The buffered: true option in observe() solves this by replaying the entries the browser recorded before the observer was created, so you do not miss the early events that define load-time metrics. Omitting it is a common cause of an LCP that reads as zero or an FCP that never fires β€” the events happened, but the observer was not listening yet, and without buffering they are gone.

The corollary is that you should register your observers as early as possible and always with buffered: true for load-time metrics. For interaction metrics like INP that accumulate over the session, buffering matters less because the events come after the observer exists, but there is no harm in setting it. Getting this detail right is the difference between field data that matches Google’s own measurement and field data that mysteriously under-reports early metrics. It is a small flag with an outsized effect on correctness, which is why it belongs in the mental checklist for every observer you wire up. The safest pattern in practice is to register all your load-time observers in a small inline script early in the document head, before the main bundle, so the browser is listening from the earliest possible moment and buffering covers anything that slipped through before even that ran.

Frequently Asked Questions

What is the difference between buffered:true and takeRecords()?

buffered: true is a registration-time flag: it tells the engine to replay any matching entries already sitting in the performance buffer the moment you call observe(), so entries recorded before your script ran are not lost. takeRecords() is a drain-time call: it synchronously returns and clears entries the observer has queued but not yet delivered to the callback, which is what you need in a visibilitychange handler before the page unloads.

Why do largest-contentful-paint entries only appear through PerformanceObserver?

largest-contentful-paint and layout-shift are observer-only entry types. They are never exposed through performance.getEntries() or getEntriesByType(), so a polling loop can never see them. You must register a PerformanceObserver β€” ideally with buffered: true β€” to receive them at all.

Should I use one observer per entry type or entryTypes with an array?

Prefer one observer per type. The single-type form observe({ type: '...', buffered: true }) supports buffered and type-specific options; the plural observe({ entryTypes: ['a','b'] }) observes several types at once but silently ignores buffered and those options. For anything you care about buffering β€” LCP, FCP, early long tasks β€” use a dedicated single-type observer.

How do I keep the observer callback from becoming a long task itself?

Do as little synchronous work as possible inside the callback. Push entries into a plain array and process, summarize, or serialize them later in a requestIdleCallback. If you profile the callback and it exceeds roughly 2ms, or it shows up in the longtask stream you are collecting, that is the signal to defer its work off the critical path.

Why did my LCP or CLS metric come back empty in the field?

Almost always a registration race or a missing flush. Without buffered: true the early LCP and FCP entries are recorded before your analytics bundle parses and are gone by the time you observe. And without a takeRecords() plus sendBeacon in a visibilitychange handler, the final layout shift or LCP value is never sent because the page unloads before the next async callback runs.