How to Send Web Vitals Reliably with sendBeacon on visibilitychange

This walkthrough belongs to RUM Beacons & Field Data Collection under Core Web Vitals & Measurement. The symptom is specific and common: your dashboard receives far fewer INP and CLS values than page views, mobile is worse than desktop, and the p75 you report looks suspiciously good. The measurements are correct; the delivery is losing them, and it is losing them non-randomly — the sessions most likely to be dropped are the frustrated ones that closed the tab.

Rapid Diagnosis: Prove You Are Losing Beacons

  • Compare counts, not values. Divide beacons received by page views from an independent source. Anything below ~95% is a delivery bug, not user behaviour.
  • Split by metric. LCP usually arrives; INP and CLS often do not. That asymmetry is the signature — they are the metrics that finalise at page hide.
  • Split by platform. If iOS and Android lose far more than desktop, you are almost certainly relying on unload or beforeunload, which mobile browsers frequently never fire.
  • Look for bfcache disqualification. Chrome DevTools' Application → Back/forward cache panel names the blocker. An unload listener appears there explicitly — and if it disqualifies the page from bfcache, you have also made navigation slower for real users.
The page lifecycle and the only reliable flush point From active, a page passes through hidden on tab switch or navigation and can then be frozen, restored from the back-forward cache, or discarded. The hidden transition is the last moment guaranteed to fire. Flush here, or not at all active metrics accumulating tab switch hidden visibilitychange fires frozen (bfcache) discarded restored `unload` may never fire on mobile, and registering it disables the back/forward cache entirely. `pagehide` is reliable too, but fires later and not on a plain tab switch. The hidden transition is the last point guaranteed to run on every platform.

Root Cause Analysis

1. Listening on the wrong event. unload and beforeunload are legacy events that mobile browsers routinely skip when the user switches apps or the OS reclaims the tab. Worse, registering unload disqualifies the page from the back/forward cache, so you slow down navigation in exchange for data you do not receive.

2. Using fetch without keepalive. A normal fetch is cancelled when its document is destroyed. If it has not completed by the time teardown happens — which is the common case, since you are firing it at teardown — it is aborted silently.

3. Firing one request per metric. Four separate beacons means four chances to be cut off, and browsers cap in-flight keepalive requests. The last metric to finalise, usually INP, is the one that loses.

4. Double-counting in single-page apps. The hidden transition fires on every tab switch, not only on final departure. Without a guard, a user who switches tabs three times sends three beacons for the same page view and skews every percentile toward whatever partial values were current at each flush.

Step-by-Step Resolution

1. Accumulate into a queue rather than sending immediately.

javascript
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals/attribution';

const queue = new Map();                          // one entry per metric name
const record = (m) => queue.set(m.name, {
  name: m.name, value: Math.round(m.value * 1000) / 1000,
  rating: m.rating, id: m.id, navType: m.navigationType
});
[onLCP, onINP, onCLS, onTTFB].forEach((on) => on(record));
// trade-off: a Map keyed by metric name overwrites earlier values for the same
// metric, which is what you want for a final report. If you need the interim
// series for debugging, use an array and dedupe server-side instead.

Expected outcome: at most one entry per metric per page view, ready to send as a single payload.

2. Flush on the hidden transition with sendBeacon.

javascript
function flush() {
  if (!queue.size) return;
  const payload = JSON.stringify({
    v: 1, url: ROUTE_PATTERN, visitId: VISIT_ID, metrics: [...queue.values()]
  });
  queue.clear();                                  // clear BEFORE sending
  const blob = new Blob([payload], { type: 'application/json' });
  if (!navigator.sendBeacon('/rum', blob)) {
    fetch('/rum', { body: payload, method: 'POST', keepalive: true }).catch(() => {});
  }
}

addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});
addEventListener('pagehide', flush);              // belt and braces, idempotent
// trade-off: sendBeacon returns only whether the request was QUEUED. It cannot
// report a 500 from your collector, so client-side success is not evidence of
// delivery — monitor received volume server-side.

Expected outcome: delivery rates typically move from 60–80% to above 98%, with the largest gain on mobile.

3. Make repeat flushes safe. Clearing the queue before sending means a second hidden transition sends nothing unless new metrics arrived. If your collector needs to merge partial reports, key them on the visit ID so late arrivals update the same row rather than creating a new one.

javascript
// Collector side: upsert on (visitId, metricName) so a second flush that
// carries a larger INP replaces the earlier value instead of adding a row.
// trade-off: upserts cost more than appends at high volume. If your store
// prefers appends, write append-only and take the max per (visitId, metric)
// at query time instead.

Expected outcome: tab switching no longer inflates your page-view count or distorts percentiles.

4. Keep the payload small and the endpoint dumb. Stay well under the ~64KB sendBeacon limit, and have the collector return 204 immediately, writing to a queue rather than a database on the request path. A collector that takes 300ms to respond does not slow the user down — sendBeacon is fire-and-forget — but it will fall over under real traffic.

Verification

Instrument the check rather than trusting a manual test. Compare beacons received against page views over a full day, split by platform, and confirm the ratio is above 95% everywhere. Then confirm the per-metric completeness: of the visits that reported LCP, what fraction also reported CLS? A widening gap means the flush is still being missed.

Manually, the reliable reproduction is a tab switch:

javascript
// In DevTools, with the Network panel filtered to your collector path:
// 1. Load the page, interact with it once so INP has a value.
// 2. Switch to another tab. A single POST to /rum should appear immediately.
// 3. Switch back and away again — no second beacon unless new metrics arrived.

Finally, confirm you have not broken the back/forward cache: open Application → Back/forward cache, run the test, and check that the page is eligible. If a legacy unload listener still exists anywhere in your bundle or in a third-party tag, it will be named there — and removing it is both a data fix and a navigation-speed fix.

FAQ

Should I use pagehide or visibilitychange?

Use visibilitychange as the primary trigger and pagehide as a secondary one. The hidden transition fires in every case that matters, including app switching on mobile where pagehide may come much later or not at all. Registering both is safe as long as the flush is idempotent — clear the queue before sending and a second call is a no-op.

What happens to metrics after a back/forward cache restore?

The page resumes with its existing state, and web-vitals reports a fresh set of values with navigationType set to back-forward-cache. Keep those rows but analyse them separately: a restore has a near-zero LCP that will drag your cold-load p75 downward and hide regressions if you mix the two populations.

Is sendBeacon blocked by ad blockers or consent tooling?

The API itself is not, but the destination can be. A collector on a path containing "analytics" or on a known third-party domain is a common block target, which shows up as a uniform loss across a subset of users. Host the collector on your own origin under a neutral path, and monitor the received-to-pageview ratio by country and browser — a sharp drop in one segment usually means blocking, not a code bug.

How large can a beacon payload be?

sendBeacon is subject to a browser-enforced quota — commonly around 64KB across all queued beacons — and returns false when the payload is rejected. A batch of four metrics with attribution is well under a kilobyte, so the limit only becomes relevant if you attach large diagnostic blobs. Keep the payload to scalar fields and short selectors, and check the return value so a rejection falls through to the keepalive fetch instead of vanishing.

Should the collector be on a subdomain or a path?

A path on the main origin, if you can. It reuses the existing connection, avoids a DNS lookup and TLS handshake at the moment the page is being torn down, and is far less likely to be blocked than a recognisable third-party endpoint. A subdomain still costs a fresh connection unless the browser can coalesce it, which is not guaranteed. Reserve a separate host for cases where the collector genuinely cannot share infrastructure with the site.

Do I need to handle the page freeze and resume events?

For most sites, no — the hidden transition already fires before a freeze, so your data is out. It becomes relevant if you keep long-lived state in memory that should be flushed differently on resume, or if you periodically flush during long visible sessions and want to avoid a flush while frozen. If you add a periodic flush, gate it on document.visibilityState === 'visible' so it cannot fire in a background tab.

/html>