An analytics module creates a PerformanceObserver for the 'largest-contentful-paint' entry type, but on fast page loads its callback never fires. Which observe() option was most likely omitted, and what does that option do?
answer
- observers are not retroactive
- the entry already happened
- a flag replays what the timeline holds
- buffered works with type, not entryTypes
- register the observer as early as possible
basics
~20 sThe observe() call omitted buffered: true. A PerformanceObserver normally only sees entries created after it registers, so an observer starting late misses events the browser already recorded. buffered: true replays matching entries already sitting in the performance timeline, and it only works with the single-type form of observe().
solid answer
~50 sA `PerformanceObserver` is not retroactive by default: it delivers entries created *after* `observe()` runs. Anything that happened while your bundle was still downloading, parsing and executing is already in the browser's performance timeline, and the observer will never hear about it. On a fast load the largest contentful paint can easily land before third-party analytics code executes, so the callback stays silent. The fix is `observer.observe({ type: 'largest-contentful-paint', buffered: true })` — `buffered` tells the browser to flush the matching entries already buffered in the timeline into your callback on its first invocation. The important gotcha is that `buffered` is only honoured alongside the singular `type` option; if you pass the plural `entryTypes: ['largest-contentful-paint']`, the flag is ignored and you are back to future-only delivery. That is why every field-vitals implementation registers one observer per entry type with `type` plus `buffered: true`.
code
javascript · 17 linesconst types = ['largest-contentful-paint', 'layout-shift', 'longtask'];
for (const type of types) {
if (!PerformanceObserver.supportedEntryTypes.includes(type)) continue;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(entry.entryType, Math.round(entry.startTime));
}
});
try {
observer.observe({ type, buffered: true });
} catch {
// older engines throw on unknown types
}
}go deeper
Know that a PerformanceObserver only hears about entries created after you call observe(), and that a metric which fired earlier in the load is simply missed unless you ask for the buffered ones.
Explain the mechanics: the performance timeline buffers entries independently of any observer, buffered: true replays them into the first callback, and the flag is only honoured with the singular type option.
Demonstrate that you have debugged this in production — silent gaps biased toward fast loads, resource-buffer overflow at 250 entries, and moving the observer registration into an inline head snippet.
Own the correctness of the collection layer itself: how you prove the RUM script is not systematically under-sampling, and who is accountable when a dashboard is quietly wrong rather than obviously broken.
## The timeline and the observer are two different things The browser maintains a *performance timeline*: an ordered buffer of `PerformanceEntry` objects it fills in as things happen — the navigation, each resource, paint milestones, layout shifts, the largest contentful paint candidate, long tasks, and any marks and measures your code adds. That buffer exists from the moment the document starts, regardless of whether anyone is watching. `PerformanceObserver` is a *subscription* to future additions to that buffer. `new PerformanceObserver(callback)` then `observer.observe({...})` registers interest; from that point on the browser batches matching new entries and invokes your callback with a `PerformanceObserverEntryList`. Entries recorded *before* `observe()` ran are not delivered — the subscription starts when you subscribe. ## Why that breaks metric collection specifically Metric entries are recorded early and are recorded once. On a well-optimised page the largest contentful paint may occur at 800 ms; a deferred analytics bundle might not evaluate until 1200 ms. The entry exists, sitting in the timeline, but the observer registered after it and hears nothing. The failure is silent — no error, no entry, and it correlates with fast loads, which is exactly the wrong bias to introduce into your data. Same story for `first-contentful-paint` (a `paint` entry), early `layout-shift` entries during initial render, `navigation`, and any `resource` entries for assets fetched before your script ran. ## What buffered: true does ```javascript const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { report(entry); } }); observer.observe({ type: 'largest-contentful-paint', buffered: true }); ``` With `buffered: true`, the browser queues the already-buffered matching entries and delivers them in the observer's first callback, then continues with live delivery. From your callback's point of view there is no difference between a replayed entry and a live one; ordering by `startTime` is preserved. ## The type vs entryTypes trap `observe()` accepts two mutually exclusive shapes: - `observe({ type: 'layout-shift', buffered: true })` — a single entry type, and the only form where `buffered` is meaningful. - `observe({ entryTypes: ['mark', 'measure'] })` — several types at once, and `buffered` is **ignored** here. Passing both `type` and `entryTypes` in one call throws a `TypeError`. And an observer that has been used with `type` cannot later be switched to `entryTypes` (or vice versa) — that raises an error too. In practice this pushes you to one observer per entry type, which is also what you want, because different metrics need different finalisation logic anyway. The reason the plural form ignores `buffered` is worth internalising: `entryTypes` was the original API shape and predates buffering, and it is the shape people reach for when they want "everything", which is exactly when they most often get bitten. ## Buffer limits The replay is only as good as what the browser kept. Each entry type has a buffer size limit; the resource timing buffer in particular defaults to 250 entries, after which new resource entries are dropped and a `resourcetimingbufferfull` event fires on `performance`. On an asset-heavy page that is easy to hit before your script runs. You can raise it with `performance.setResourceTimingBufferSize(n)` or drain it with `performance.clearResourceTimings()`, but the honest fix is to register the observer as early as possible — inline, in the document head, before the rest of the bundle. ## Feature detection Not every browser supports every entry type, and observing an unsupported type is a no-op at best. The static list is the safe check: ```javascript if (PerformanceObserver.supportedEntryTypes.includes('layout-shift')) { // safe to observe } ``` Wrapping `observe()` in a `try/catch` is also common, because older implementations throw for unknown types rather than ignoring them. ## The takeaway for instrumentation code Three rules follow. Register performance observers as early in the document as you can — an inline snippet beats a deferred bundle. Always use the `type` + `buffered: true` form for anything that can happen before your code runs. And never conclude from an empty dataset that the page had no layout shifts or no LCP; conclude first that your observer started too late.
- Why can't you just call performance.getEntriesByType('largest-contentful-paint') on load instead?You can read it that way, but you would be sampling a moving target. LCP emits successive candidates as larger elements paint, so a single read at `load` may catch an early candidate and miss the final one, and it gives you no hook for the interaction that ends LCP reporting. An observer with `buffered: true` gets both the history and the future, which is what finalisation needs.
- What is the effect of passing both type and entryTypes in one observe() call?It throws a `TypeError`. The two shapes are mutually exclusive by design. Related trap: once an observer has been used with one shape, calling `observe()` again with the other shape on the same instance is also an error. The practical consequence is one observer instance per entry type, which is how the standard field-metric libraries are built anyway.
- Your resource timing data is missing entries for late-loading assets. What would you check first?The resource timing buffer, which defaults to 250 entries. Once it is full the browser silently drops new `resource` entries and fires `resourcetimingbufferfull` on `performance`. Either listen for that event and drain with `performance.clearResourceTimings()` after reporting, or raise the ceiling with `performance.setResourceTimingBufferSize(n)`. An asset-heavy page with sprites, fonts and third-party beacons hits 250 quickly.
- How do you avoid errors when observing an entry type a browser does not implement?Check `PerformanceObserver.supportedEntryTypes`, a static array of the entry types the browser knows about, before calling `observe()` — and wrap the call in a `try/catch` as a belt-and-braces measure, because some implementations throw on unknown types rather than ignoring them. This matters for `layout-shift`, `longtask` and `long-animation-frame`, which are not universally supported.
saying these in an interview costs you the question
- Assumes a PerformanceObserver reports entries from before it registered
- Thinks buffered: true works with the entryTypes array form
- Concludes an empty dataset means the page had no layout shifts
- Registers the observer in a deferred analytics bundle
- Believes every browser supports every entry type