Integrating Consent Mode with RUM Beacons
Your legal model requires explicit consent before you store or read any persistent identifier, yet the most valuable field data — the Largest Contentful Paint of the very first paint, the Cumulative Layout Shift accumulated during early load — happens before the user has touched the consent banner. If you fire beacons immediately you collect data you have no basis to keep; if you wait for consent you lose the metrics that matter most. This page, part of Privacy-Compliant Tracking, shows the production pattern that resolves the conflict: default every signal to denied, buffer metrics captured before the choice, read a Consent Management Platform (CMP) or Google Consent Mode v2 analytics_storage signal, and flush the buffered beacons only after a grant — while keeping a cookieless aggregate path alive when consent is denied so you are never fully blind.
The key insight is that there are two transmission modes, not one. A full beacon (with a persistent visitor id, joinable across pageloads) requires analytics_storage = granted. A cookieless aggregate beacon (no identifier, no cross-session linking) needs no consent at all and is the fallback. The consent listener decides which path a buffered batch takes at flush time, never before.
Prerequisites
Before wiring consent gating, confirm the following are in place:
- A working RUM beacon path. The mechanics of receiving and validating payloads belong to Self-Hosted Beacon Collection; this page only governs when and how a beacon is allowed to leave the browser.
- The web-vitals library installed, so Interaction to Next Paint, LCP, CLS, FCP and TTFB follow Google’s exact algorithms.
- A consent source: either Google Consent Mode v2 (
gtag('consent', …)writing to the dataLayer) or an IAB TCF v2.2 CMP exposing__tcfapi. This page handles both. - A documented decision that
analytics_storageis the signal governing your RUM identifier. RUM telemetry maps to the analytics storage purpose, not advertising. - A cookieless aggregate ingestion route that needs no consent, used as the denied-state fallback.
Threshold reference
Whichever consent path a beacon takes, aggregate the resulting values at the 75th percentile so a single slow tail load does not move the headline number. Use these exact bands for alerting and dashboards on the collected data:
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| LCP | ≤ 2.5 s | ≤ 4.0 s | > 4.0 s |
| INP | ≤ 200 ms | ≤ 500 ms | > 500 ms |
| CLS | ≤ 0.1 | ≤ 0.25 | > 0.25 |
| FCP | ≤ 1.8 s | — | — |
| TTFB | ≤ 800 ms | — | — |
The consent state itself maps to one of three transmission outcomes:
| Consent state | analytics_storage |
RUM action | Identifier |
|---|---|---|---|
| Pending (no choice yet) | unset / default | Buffer in memory, send nothing | none |
| Granted | granted |
Flush full beacons | persistent visitor id |
| Denied | denied |
Flush cookieless aggregate beacons | none |
How to gate RUM beacons on consent
1. Default every consent signal to denied
Set the default consent state to denied before any tag, including your RUM bootstrap, runs. With Consent Mode v2 this is a gtag('consent', 'default', …) call placed first in the head. Until the user acts, nothing that depends on storage may transmit.
// In <head>, before the RUM script or any analytics tag loads.
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('consent', 'default', {
analytics_storage: 'denied',
ad_storage: 'denied',
wait_for_update: 500 // ms to await a CMP update before tags assume default
});
Why: defaulting to denied makes the absence of a decision fail safe — a user who never interacts with the banner never has an identified beacon sent. wait_for_update gives an asynchronous CMP a short window to override the default before any tag commits.
2. Capture vitals into an in-memory buffer
Register the web-vitals handlers as early as possible so buffered performance entries are not missed, but route every value into an array instead of sending it. The buffer holds metrics that occur before the consent choice is known.
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
const buffer = [];
let consentState = 'pending'; // 'pending' | 'granted' | 'denied'
function capture({ name, value }) {
buffer.push({ name, value: Math.round(value * 1000) / 1000, t: Date.now() });
// If consent already resolved, flush opportunistically.
if (consentState !== 'pending') flush();
}
onLCP(capture);
onINP(capture);
onCLS(capture);
onFCP(capture);
onTTFB(capture);
Why: LCP and the early CLS window resolve in the first second of load, long before most users dismiss a banner. Buffering preserves those observations so that a later grant does not cost you the metrics that matter most, while a later deny still lets them flow down the cookieless path.
3. Read the consent signal — Consent Mode v2 and TCF
Subscribe to consent updates from whichever source you run. For Google Consent Mode v2, observe analytics_storage updates on the dataLayer. For an IAB TCF v2.2 CMP, call __tcfapi('addEventListener', …) and check Purpose 1 (storage) plus your analytics vendor. Normalize both to a single granted/denied resolution.
function onConsentResolved(state) {
if (consentState === state) return;
consentState = state; // 'granted' or 'denied'
flush(); // route the buffered batch now
}
// Google Consent Mode v2: watch dataLayer consent updates.
function watchConsentMode() {
const seen = new Set();
const original = dataLayer.push.bind(dataLayer);
dataLayer.push = (...args) => {
for (const a of args) {
if (a && a[0] === 'consent' && (a[1] === 'update' || a[1] === 'default')) {
const grant = a[2] && a[2].analytics_storage;
if (grant && !seen.has(grant)) {
seen.add(grant);
onConsentResolved(grant === 'granted' ? 'granted' : 'denied');
}
}
}
return original(...args);
};
}
// IAB TCF v2.2: resolve via the CMP API when present.
function watchTcf() {
if (typeof window.__tcfapi !== 'function') return;
window.__tcfapi('addEventListener', 2, (data, success) => {
if (!success || !data) return;
if (data.eventStatus !== 'useractioncomplete' &&
data.eventStatus !== 'tcloaded') return;
const p = data.purpose && data.purpose.consents;
onConsentResolved(p && p[1] ? 'granted' : 'denied'); // Purpose 1 = storage
});
}
watchConsentMode();
watchTcf();
Why: the two consent frameworks expose different APIs, but both ultimately answer one question — may you write a persistent analytics identifier? Normalizing to a single state keeps the flush logic framework-agnostic, so swapping CMPs never touches the beacon code.
4. Flush buffered beacons on the granted path
When consent resolves to granted, attach a persistent visitor id (read or minted in storage that consent now permits) and send the buffered metrics as a full, joinable beacon via sendBeacon.
function visitorId() {
let id = localStorage.getItem('rum_vid');
if (!id) { id = crypto.randomUUID(); localStorage.setItem('rum_vid', id); }
return id; // only ever called on the granted path
}
function sendFull(metrics) {
const body = JSON.stringify({
v: 1, mode: 'full', vid: visitorId(),
path: location.pathname, metrics
});
const blob = new Blob([body], { type: 'application/json' });
if (!navigator.sendBeacon('/rum', blob)) {
fetch('/rum', { method: 'POST', body: blob, keepalive: true,
headers: { 'Content-Type': 'application/json' } })
.catch(() => {});
}
}
Why: localStorage is only ever read on the granted branch, so the consent gate is structural — there is no code path that writes the identifier without a grant. The persistent id is what enables cross-pageload session joins that the denied path deliberately forgoes.
5. Flush buffered beacons on the denied path
When consent resolves to denied, send the same buffered metrics with no identifier, no localStorage read, and a cookieless mode flag so ingestion routes the row to the aggregate-only store. This keeps you measuring p75 from the full population, not just consenting users.
function sendCookieless(metrics) {
const body = JSON.stringify({
v: 1, mode: 'cookieless',
path: location.pathname.replace(/\/\d+(?=\/|$)/g, '/:id'),
metrics
});
const blob = new Blob([body], { type: 'application/json' });
if (!navigator.sendBeacon('/rum', blob)) {
fetch('/rum', { method: 'POST', body: blob, keepalive: true,
headers: { 'Content-Type': 'application/json' } })
.catch(() => {});
}
}
let flushed = false;
function flush() {
if (consentState === 'pending' || flushed || buffer.length === 0) return;
const metrics = Object.fromEntries(buffer.map((m) => [m.name, m.value]));
if (consentState === 'granted') sendFull(metrics);
else sendCookieless(metrics);
flushed = true;
buffer.length = 0;
}
Why: a denied choice must not mean zero data. The cookieless aggregate path carries no persistent identifier and so needs no consent, yet it still contributes one observation per metric to the percentile calculation — eliminating the sampling bias that would otherwise skew your field numbers toward only the users who clicked “accept.”
6. Force a final flush on page hide
If the user never resolves consent, the buffer would otherwise be lost on unload. On visibilitychange → hidden, if consent is still pending, send the buffer down the cookieless path as a last resort — a no-identifier aggregate beacon is always lawful.
addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden') return;
if (consentState === 'pending') {
consentState = 'denied'; // pending at unload is treated as no-consent
}
flush();
});
// Safari can skip the hidden transition; pagehide is the backstop.
addEventListener('pagehide', () => {
if (consentState === 'pending') consentState = 'denied';
flush();
});
Why: treating an unresolved choice at unload as “denied” is the conservative reading of consent law — you transmit only the cookieless aggregate, never an identified beacon, for users who left without deciding. Sending on hide also guarantees the final INP and CLS values are included.
Verifying it works
- DevTools › Application › Local Storage: load the page, dismiss the banner with reject, and confirm no
rum_vidkey is written. Accept on a second load and confirm the key now appears — proving the identifier is gated on grant, not on page load. - DevTools › Network: filter for
/rum. With consent pending, switch tabs without choosing and confirm exactly one request with"mode":"cookieless". Accept, reload, and confirm"mode":"full"with avid. - Buffer timing: add a temporary
console.log(buffer.length)incaptureand confirm LCP and FCP land in the buffer before any beacon fires when you delay the consent choice. - RUM dashboard: verify both
fullandcookielessrows contribute to the same p75 series per route, and that nocookielessrow carries avid. - Pending-at-unload: open the page, immediately close the tab without touching the banner, and confirm a single
cookielessbeacon was sent — never afullone.
Edge cases & gotchas
- Double flush after grant: if metrics arrive after consent already resolved,
capturecallsflush()again. Theflushedguard prevents a second beacon; if you need late-arriving INP to still ship, split the buffer into a sent set and a pending set rather than a single boolean. - CMP loads after first paint: a slow third-party CMP can resolve consent hundreds of milliseconds late.
wait_for_updatecovers the default window, but ensure your flush is idempotent so a latetcloadedevent does not re-send. - TCF “legitimate interest” vs “consent”: some vendors register analytics under legitimate interest, which TCF exposes via
purpose.legitimateInterests, notpurpose.consents. Decide explicitly which basis governs your RUM id and read the matching field — do not assume Purpose 1 consent alone. - bfcache restores re-running the listener: a page restored from back/forward cache keeps
flushed = true, so a restore can suppress a fresh beacon. Resetflushedand clear the buffer onpageshowwhenevent.persistedis true. - Consent changed mid-session: a user who later revokes consent should stop sending
fullbeacons immediately. Re-read the consent state on each flush rather than caching it at script init, which theconsentStatevariable already does. - Granted but storage blocked: private-mode browsers can grant consent yet throw on
localStorage.setItem. WrapvisitorId()in a try/catch and fall back to the cookieless path so a storage exception never drops the beacon entirely.
FAQ
Which Consent Mode v2 signal governs RUM beacons?
analytics_storage. RUM performance telemetry is an analytics-storage purpose, not advertising, so your gate reads analytics_storage and ignores ad_storage. When it is granted you may attach a persistent visitor id; when denied you send only the cookieless aggregate.
Do I lose early LCP and CLS while waiting for the consent choice?
No, provided you buffer. The web-vitals handlers capture LCP, FCP, and the early CLS window into an in-memory array immediately; the buffer is only flushed once consent resolves. A later grant ships the full buffered batch, and a later deny ships the same values down the cookieless path.
Is the cookieless fallback lawful without consent?
The cookieless aggregate beacon carries no persistent identifier and reads no device storage, so under ePrivacy it does not trigger the storage-access consent gate and normally rests on legitimate interest. Keeping that path alive is what prevents your p75 from being biased toward only consenting users.
Related
- Privacy-Compliant Tracking — the parent overview of consent-aware, PII-minimized RUM patterns.
- GDPR-Compliant RUM Without Cookies — the cookieless aggregate design this page reuses as its denied-state fallback.
- Self-Hosted Beacon Collection — building the first-party ingestion endpoint that distinguishes full from cookieless rows.