skip to content

A RUM script observes 'largest-contentful-paint' and 'event' entries in the browser and POSTs each entry to an analytics endpoint as it arrives. Why does that produce wrong Core Web Vitals numbers, and what is the correct collection pattern?

level: seniorimportance: should knowfreq 46%

answer

  1. an entry is a draft, not a result
  2. the last candidate is the one that counts
  3. several events, one interaction
  4. finalise when the page goes away
  5. a beacon that outlives the document

basics

~20 s

Vitals are not single events. LCP emits successive candidates until the first interaction, and INP is derived by grouping many 'event' entries by interactionId, so per-entry reporting sends drafts, not results. Accumulate in memory, finalise once, and send with sendBeacon when the page is hidden.

solid answer

~60 s

Each raw entry is an observation, not a metric. The browser emits a `largest-contentful-paint` entry every time a larger element paints, so the value you want is the *last* one — reporting each arrival means the earliest, smallest candidate dominates your dataset. `event` entries are worse: a single tap produces several (pointerdown, pointerup, click), which you group by `interactionId` and reduce to one latency per interaction before picking the page's representative value, and by default the observer only sees events above roughly 100 ms unless you lower `durationThreshold`. `layout-shift` entries similarly need windowing before they become a CLS score. So the pattern is: buffered observers accumulate state in memory; the value is finalised when the page is actually going away; and it is sent once, in a `visibilitychange` handler at `hidden` (with `pagehide` as backup), via `navigator.sendBeacon` so the request survives. Do not use `unload` — it is unreliable on mobile and disqualifies the page from the back/forward cache. In practice this is why teams use the `web-vitals` library instead of hand-rolling it.

code

javascript · 17 lines
javascript
let lcp = 0;
let sent = false;

new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const last = entries[entries.length - 1];
  lcp = last.renderTime || last.loadTime || last.startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true });

function flush() {
  if (sent || document.visibilityState !== 'hidden') return;
  sent = true;
  navigator.sendBeacon('/rum', JSON.stringify({ lcp: Math.round(lcp) }));
}

addEventListener('visibilitychange', flush, true);
addEventListener('pagehide', flush, true);

go deeper

for a junior

Know that these metrics are computed from many raw entries rather than delivered as a finished number, and that the page's value is only known once the user leaves.

for a middle

Explain the reductions concretely: keep the last LCP candidate, group event entries by interactionId, window layout shifts, and finalise on visibilitychange rather than on load.

for a senior

Demonstrate you have shipped this — sendBeacon over unload, the back/forward-cache penalty of an unload listener, duplicate-send guards, durationThreshold tuning, and the decision to adopt a maintained library instead of owning spec drift.

for a principal

Own the build-versus-adopt call and its consequences: who keeps the collector correct as the metric definitions change, how you validate your field numbers against public field data, and what a wrong dashboard costs the organisation.

## Entries are observations; metrics are reductions The mistake is treating the performance timeline as an event stream of finished measurements. It is not. Each entry type reports raw observations, and each metric is a *reduction over* those observations that only becomes final when the page's life ends. Shipping each entry raw and reducing server-side is possible in principle, but it multiplies your beacon volume and moves subtle, spec-shaped logic into a place where nobody will maintain it correctly. ## Largest contentful paint: keep the last candidate The browser emits an entry each time a new largest contentful element paints. A text block paints at 600 ms; the hero image lands at 1900 ms and is bigger; you get two entries. The metric is the last one. Reporting on arrival means every page contributes its earliest candidate, and your dashboard is confidently, systematically optimistic. Reporting also *stops* at the first user interaction — a click or key press ends candidacy, on the reasoning that content painted after the user has already engaged is no longer part of the initial-load experience. So there is no "it stopped changing" signal you can wait for; you keep the latest value and finalise at the end. ```javascript let lcp; new PerformanceObserver((list) => { const entries = list.getEntries(); lcp = entries[entries.length - 1]; }).observe({ type: 'largest-contentful-paint', buffered: true }); ``` One more trap: `renderTime` on an LCP entry is 0 for a cross-origin image whose response lacks `Timing-Allow-Origin`, so collection code falls back to `loadTime`, which is marginally earlier and marginally flattering. ## Interaction latency: group by interactionId `PerformanceEventTiming` entries (`type: 'event'`) describe individual events, and one user interaction spans several of them. A tap yields `pointerdown`, `pointerup` and `click`; a key press yields `keydown`, `keypress`, `keyup`. The entries belonging to one logical interaction share a non-zero `interactionId`. The latency of *the interaction* is derived from that group — you cannot read it off any single entry, and summing the entries double-counts. Two further mechanics matter. First, the observer's `durationThreshold` option: the default reporting floor is around 100 ms, so ordinary-but-not-great interactions are invisible unless you pass a lower value (the effective minimum is 16 ms). Second, the page-level value is chosen across all interactions on the page rather than being the last or the mean, so the collector has to retain a small set of the worst interactions for the page's lifetime and pick from it at the end. ```javascript observer.observe({ type: 'event', durationThreshold: 40, buffered: true }); ``` ## Layout shifts need windowing `layout-shift` entries carry a `value` and a `hadRecentInput` flag. Shifts that immediately follow user input are excluded, and the remaining ones are grouped into windows before they become the page's score — so a collector that sums every entry it sees will over-report, sometimes dramatically on a long-lived page. This grouping rule is precisely the kind of specification detail that argues for a maintained library rather than an in-house reducer. ## Finalising and sending The page's final values are known only when the page stops being used. The reliable signal is `visibilitychange` firing with `document.visibilityState === 'hidden'` — it covers tab switches, app switches on mobile, and closing — with `pagehide` as a secondary hook for the cases a given browser handles differently. Send with `navigator.sendBeacon(url, body)`, which queues the request in the browser rather than the document, so it survives the page going away; a `fetch(url, { keepalive: true })` is the equivalent when you need headers or a response. Guard against double-sending: a page can go hidden and come back, so mark the payload as sent, and if you report again later send only the delta or a distinguishing flag. What not to use: `unload` and `beforeunload`. They are unreliable on mobile, where a tab is often discarded without them firing at all, and registering an `unload` listener makes the page ineligible for the back/forward cache — meaning your monitoring code degrades the very metric it is measuring. ## Why the library exists Everything above — last-candidate LCP, interaction grouping and threshold, shift windowing, prerender and back/forward-cache handling, the hidden-page finalisation — is what the `web-vitals` library encapsulates behind `onLCP`, `onINP`, `onCLS`, `onTTFB` and `onFCP`, each calling you back with a finalised value. It also offers an attribution build that adds diagnostic context, such as which element the LCP candidate was. Hand-rolling collection is a legitimate exercise for understanding the APIs; shipping a hand-rolled version means owning a moving specification forever. The honest interview answer names both: here is how it works underneath, and here is why I would not maintain it myself.

  • Why is unload the wrong event for sending a vitals beacon?
    Two reasons. It is unreliable — mobile browsers frequently discard a backgrounded tab without ever firing it, so you lose exactly the sessions on weak devices that you most need. And registering an `unload` listener makes the page ineligible for the back/forward cache, so the monitoring code degrades real navigation performance. Use `visibilitychange` at `hidden`, with `pagehide` as a secondary hook.
  • What does lowering durationThreshold on an 'event' observer change, and what does it cost?
    It lowers the floor above which event-timing entries are reported. The default is around 100 ms, so moderately slow interactions never reach your callback at all; passing something like 40 ms surfaces them (16 ms is the effective minimum). The cost is volume — more entries delivered, more callback work on the main thread, and more state retained per page. Pick a floor that answers your question rather than the lowest possible number.
  • Why can't you read a single event-timing entry to get an interaction's latency?
    Because one interaction spans several events. A tap produces pointerdown, pointerup and click entries; the ones belonging to the same logical interaction share a non-zero `interactionId`, and the interaction's latency is derived from that group. Any single entry describes only part of the interaction, and adding the entries up double-counts overlapping work. Group first, then reduce.
  • How would you avoid sending two beacons for one page view?
    Track a sent flag, because `visibilitychange` can fire more than once — a user backgrounds the tab, returns, and leaves again. Send the finalised payload on the first transition to hidden and mark it sent; on any later transition either send nothing, or send a clearly-flagged update carrying only what changed. Server-side, key on a per-page-view id so a duplicate can be reconciled rather than counted twice.

saying these in an interview costs you the question

  • Reporting the first largest-contentful-paint entry as the LCP
  • Summing every layout-shift entry into a CLS total
  • Treating one event-timing entry as one interaction
  • Sending the beacon from an unload handler
  • Assuming default event observation catches all slow interactions

context