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.
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
// 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:
<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:
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:
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.
Verification Steps
- Count what the flag adds: log
list.getEntries().lengthon the first callback with and withoutbuffered. On a normally-loading page the difference should be non-zero for every paint type. - Check for the ignored-flag form: grep for
entryTypesand confirm none of those calls also passbuffered. - 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
bufferedwithentryTypes. Accepted, ignored, and invisible in review. - Reporting
0for 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
resourceentries 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.
Related
- Measuring LCP with PerformanceObserver — the metric this flag most often silently zeroes
- Tracking Layout Shifts with PerformanceObserver — another type that accumulates before your bundle runs
- Correlating Long Tasks with Observer Callbacks — long tasks during bundle evaluation are exactly the buffered case
↑ Back to PerformanceObserver & Rendering Metrics