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. 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:
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.
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.
Related Guides
- Rendering Performance Metrics and Tooling β the parent overview of how field and lab measurement fit together.
- Core Web Vitals Measurement β how the entries this API delivers roll up into LCP, INP, and CLS.
- Observing Long Tasks with PerformanceObserver β the per-type repro for the
longtaskstream. - Tracking Long Animation Frames β attributing a stalled frame to the script that caused it.
- Debugging CLS with the Layout Instability API β the flush discipline for shipping the final layout shift.