buffered: true looks like a minor convenience flag. It is closer to a correctness switch: without it, an observer registered by an application bundle sees none of the paint metrics that happened during page load, and reports their absence as excellence.

Problem / Scenario Context

A team adds field monitoring. The code is registered from the main bundle, alongside the rest of the app's initialisation, and it works — longtask entries flow in, interaction timings accumulate. Then someone notices that Largest Contentful Paint is reported as 0 for almost every session, and where it is non-zero the value is implausibly small.

Nothing is throwing. The observer is correctly constructed, the callback is correctly written, the beacon is correctly sent. The problem is purely one of ordering: the paint the metric refers to happened while the bundle was still downloading, and an observer only receives entries recorded after it starts observing.

The failure mode is what makes this dangerous. A missing metric would be obvious. A metric of zero flows into a dashboard, averages happily with real values, and drags the reported percentile down — so the page looks like it is getting faster at exactly the moment monitoring was added.

Mechanics Explanation

The browser records performance entries into an internal buffer from navigation onwards, whether or not anything is listening. PerformanceObserver is a consumer attached to that stream, and by default it is attached at the point of the observe() call, receiving only what arrives afterwards.

buffered: true asks the browser to replay what it already has. On the first callback invocation, the observer receives the historical entries for that type, followed by live entries as they occur.

Two constraints matter in practice.

It only works with the singular type form. observe({ entryTypes: ['largest-contentful-paint'], buffered: true }) is accepted without complaint and the flag is ignored, which produces the exact bug this page is about, in code that looks like it has been fixed.

The buffer has a size limit per entry type. For high-volume types such as resource, older entries are dropped once the limit is reached, so buffered on a resource observer gives you a recent window rather than the full history. For the paint metrics the limits are far above what a single navigation produces, so this is not a practical concern there.

The Buffer, and Who Gets to See ItA browser-managed buffer accumulating entries from navigation onwards. Three observers attach at the same later moment. One using the singular type form with buffered true receives the historical entries plus live ones. One using the singular form without the flag receives only live entries. One using entryTypes with the flag also receives only live entries, because the flag is ignored in that form.browser buffer, filled since navigationFCP · LCP#1 · longtask · LCP#2observe({ type: 'x', buffered: true })4 historical entries replayed, then live onesobserve({ type: 'x' })0 historical entries — reports LCP as 0observe({ entryTypes: ['x'], buffered: true })flag silently ignored — also 0 historical entrieslooks fixed in review; is notAll three attach atthe same moment.Only the formmatters.

Comparison Table: Which Types Need the Flag

Entry type Recorded before your bundle runs? Needs buffered
largest-contentful-paint Almost always Yes — critical
paint (FCP) Always Yes — critical
layout-shift Usually some Yes
navigation Always, exactly one Yes
longtask Often, during bundle evaluation Yes
event / interactions Only if the reader was very quick Helpful
resource Many Yes, but subject to the buffer limit
measure / mark (your own) Only if you made them early Rarely

The pattern is that everything describing page load needs the flag, and only your own instrumentation reliably does not.

Minimal Reproducible Example

TypeScript
// Registered from the app bundle. Silently reports nothing.
new PerformanceObserver((list) => {
  console.log('LCP entries:', list.getEntries().length);   // 0, forever
}).observe({ type: 'largest-contentful-paint' });

Add buffered: true and the same page logs the two or three candidates that painted before the bundle finished.

Production-Safe Solution

Three habits remove this whole class of bug.

Register the paint observers in an inline head script, before any stylesheet or bundle, so the flag has less work to do in the first place:

HTML
<script>
  window.__vitals = { lcp: 0, cls: 0 };
  if ('PerformanceObserver' in window) {
    new PerformanceObserver((l) => {
      for (const e of l.getEntries()) window.__vitals.lcp = e.startTime;
    }).observe({ type: 'largest-contentful-paint', buffered: true });
  }
</script>

Use one observer per type, which makes the singular type form — the only one that honours the flag — the natural thing to write:

TypeScript
function watch(type: string, handler: (entries: PerformanceEntryList) => void): void {
  if (!PerformanceObserver.supportedEntryTypes?.includes(type)) return;
  new PerformanceObserver((list) => handler(list.getEntries()))
    .observe({ type, buffered: true });     // singular form, always
}

watch('largest-contentful-paint', (es) => { for (const e of es) lcp = e.startTime; });
watch('layout-shift', accumulateShifts);
watch('longtask', countLongTasks);

Distinguish "no data" from "a good score" at the reporting boundary, so a regression in the instrumentation cannot masquerade as an improvement in the page:

TypeScript
navigator.sendBeacon('/vitals', JSON.stringify({
  lcp: lcp > 0 ? Math.round(lcp) : null,     // null, never 0
  path: location.pathname,
}));

That single change is what turns a silent failure into a visible one: a dashboard can chart nulls as missing, but it will happily average zeros into your percentiles.

Zero Is Not Missing DataTwo aggregations over the same ten sessions, four of which failed to record a value. Reporting the failures as zero pulls the seventy-fifth percentile down and shows an apparent improvement. Reporting them as null excludes them from the aggregate, leaving the percentile unchanged and the missing count visible as its own signal.reported as 0values: 0,0,0,0,2100,2300,2400,2600,2800,3100p752400msmissing sessions visible?nothe page appears to have got fasterreported as nullvalues: 2100,2300,2400,2600,2800,3100p752800msmissing sessions visible?yes — 4 of 10the instrumentation gap shows as its own numberA missing measurement is data about your monitoring. A zero is a lie about your page.

Registration Placement, RankedThree places to register a performance observer, ranked by how much history the buffered flag has to recover. An inline head script misses nothing. A deferred module misses the early paints but recovers them from the buffer. A component effect registers after hydration and depends entirely on the buffer, which does not exist for observers created with the entryTypes form.inline <head> script — bestregisters before any paint; the flag has almost nothing left to recoverdeferred module — acceptablemisses the early paints, recovers them from the buffer — provided the flag is setcomponent effect — fragiledepends entirely on the buffer, and unmounting the component disconnects the observer

Verification Steps

  • Count what the flag adds: log list.getEntries().length on the first callback with and without buffered. On a normally-loading page the difference should be non-zero for every paint type.
  • Check for the ignored-flag form: grep for entryTypes and confirm none of those calls also pass buffered.
  • Force a late registration by wrapping the observer setup in a setTimeout(..., 3000) in a scratch build; without the flag every paint metric should go to zero, which confirms the mechanism.
  • Look for zeros in the field data. A non-trivial share of exactly-zero LCP values is the fingerprint of this bug in production.

Common Mistakes to Avoid

  • Passing buffered with entryTypes. Accepted, ignored, and invisible in review.
  • Reporting 0 for a missing measurement. It corrupts every aggregate it touches.
  • Registering paint observers inside a component effect. By then the paint has happened; the flag saves you, but the head script is better.
  • Assuming the buffer is unlimited. For resource entries on a heavy page it is not, and older ones are dropped.

FAQ

What exactly does buffered replay?

The entries of that type the browser already recorded before your observe call, delivered in the first callback invocation ahead of any live entries. It is not a subscription to history in general — each observer gets one replay of its own entry type at attach time.

Why is buffered ignored when I use entryTypes?

The flag is only defined for the singular type form of observe. The entryTypes form predates it and silently drops it, which is why a call that reads as if it has been fixed often has not been. Register one observer per type instead.

Is a zero LCP value ever legitimate?

No. Zero means the observer never received an entry, which is a data-collection failure rather than an instantaneous paint. Report missing measurements as null so aggregates exclude them and the gap is visible as its own number.

Does the buffer hold everything since navigation?

For the paint metrics, effectively yes — the per-type limits are far above what one navigation produces. For high-volume types such as resource the buffer is capped and older entries are dropped, so a late observer gets a recent window rather than the full history.


↑ Back to PerformanceObserver & Rendering Metrics