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.
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.