You built an in-house consent UI, but every third-party SDK reads consent a different way — one polls a global flag, one checks a cookie, one never re-checks after init — and there is no standard channel for an SDK to subscribe to consent changes, so revocations silently fail to propagate.
Triage: Confirming the Missing Subscription Contract
The symptom is not “consent is wrong” — it is “consent changes do not reach every consumer.” Confirm that before building the bus.
-
Inventory how each SDK reads consent. For every third-party integration, grep the bundle for how it decides to fire: a global boolean, a cookie read, a
dataLayerpush, or nothing. A mix of mechanisms is the tell. -
Toggle consent and watch propagation. Grant consent, then revoke it in your in-house UI. In DevTools → Network, filter by each vendor domain. Any vendor that keeps sending after revocation never learned about the change.
-
Check for a re-subscription path. In the console, look for a way to register a callback on consent change:
// If nothing like this exists, there is no subscription contract. window.__tcfapi?.('addEventListener', 2, () => {}); // TCF has one typeof window.consentBus?.subscribe; // your bus does not exist yet -
Run the reproduction checklist:
Root Cause: No Pub/Sub Contract Between the CMP and Its Consumers
A commercial CMP works because it publishes a stable interface: IAB’s __tcfapi('addEventListener', …) fires every consumer’s callback on every state change. An in-house consent UI that merely writes a cookie or flips a global has no such contract. SDKs cannot subscribe to something that does not broadcast, so each one improvises — and the improvisations diverge, especially on revocation, where an SDK that cached its “ready” state at init has no reason to re-check.
The fix is to give your custom CMP the one thing the commercial ones have: a consent bus — a small pub/sub broker that owns the state, persists it, and notifies subscribers on every change. It should mirror the shape of __tcfapi closely enough that SDK authors already know how to consume it (subscribe, getState, setState), while staying framework-agnostic. This is the build path referenced in the guide to selecting and integrating a consent management platform for teams that need the consent record and timing fully under their own control.
The bus is also what makes cross-vendor propagation tractable: once every consumer subscribes to one broker, syncing consent states across multiple vendors becomes a matter of the bus broadcasting, rather than each SDK being wired individually.
Resolution: A Production-Safe ConsentBus
Implement the bus as an EventTarget subclass so you inherit the browser’s own listener machinery (add/remove/dispatch) rather than reimplementing it. It owns a single state object, persists it to localStorage, restores it on construction, and exposes a TCF-like surface: subscribe, getState, setState.
// consent-bus.js
// A framework-agnostic consent broker. One instance owns consent state,
// persists it, and notifies every subscriber on change.
const STORAGE_KEY = 'consent_state_v1';
const SCHEMA_VERSION = 1;
// The canonical shape every consumer receives. Keep it flat and boolean.
const DEFAULT_STATE = Object.freeze({
schema: SCHEMA_VERSION,
analytics: false,
ads: false,
adUserData: false,
adPersonalization: false,
functional: true, // strictly necessary — always on
updatedAt: 0
});
export class ConsentBus extends EventTarget {
#state;
constructor() {
super();
this.#state = this.#restore() ?? { ...DEFAULT_STATE };
}
// --- Public API (mirrors the __tcfapi consumer contract) ---
// Return an immutable snapshot. Callers must never mutate state directly.
getState() {
return Object.freeze({ ...this.#state });
}
// Merge a partial choice, persist, and notify. Ignores unknown keys.
setState(partial) {
const next = { ...this.#state };
for (const key of Object.keys(DEFAULT_STATE)) {
if (key in partial && typeof partial[key] === 'boolean') {
next[key] = partial[key];
}
}
next.updatedAt = Date.now();
next.schema = SCHEMA_VERSION;
this.#state = next;
this.#persist(next);
this.#emit(next);
}
// Register a listener; returns an unsubscribe function so callers cannot leak.
subscribe(handler) {
const wrapped = (event) => handler(event.detail);
this.addEventListener('consentchange', wrapped);
// Fire immediately with the current state so late subscribers are not stranded.
handler(this.getState());
return () => this.removeEventListener('consentchange', wrapped);
}
// --- Internals ---
#emit(state) {
this.dispatchEvent(new CustomEvent('consentchange', { detail: Object.freeze({ ...state }) }));
}
#persist(state) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {
// QuotaExceededError or disabled storage: degrade to in-memory only.
if (!(e instanceof DOMException)) throw e;
console.warn('[ConsentBus] persistence unavailable — state is in-memory only.');
}
}
#restore() {
let raw;
try {
raw = localStorage.getItem(STORAGE_KEY);
} catch {
return null; // storage blocked (e.g. some private modes)
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
// Reject stale schemas rather than trusting an old shape.
if (parsed?.schema !== SCHEMA_VERSION) return null;
return { ...DEFAULT_STATE, ...parsed };
} catch {
return null;
}
}
}
// Single shared instance — the source of truth for the page.
export const consentBus = new ConsentBus();
Wire cross-tab persistence by listening for localStorage mutations from other tabs, so a choice made in one tab reaches subscribers everywhere:
// cross-tab.js — keep tabs coherent without a second copy of the state.
import { consentBus } from './consent-bus.js';
window.addEventListener('storage', (event) => {
if (event.key !== 'consent_state_v1' || !event.newValue) return;
try {
const incoming = JSON.parse(event.newValue);
// Re-broadcast the remote change locally without re-persisting (avoids a loop).
consentBus.setState(incoming);
} catch { /* ignore malformed cross-tab payloads */ }
});
Consumers subscribe once and translate the state into their own dialect. The subscription fires immediately with the current state, so an SDK that loads late is never stranded on a stale value:
// consumers.js
import { consentBus } from './consent-bus.js';
// Google Consent Mode v2 consumer.
consentBus.subscribe((state) => {
gtag('consent', 'update', {
analytics_storage: state.analytics ? 'granted' : 'denied',
ad_storage: state.ads ? 'granted' : 'denied',
ad_user_data: state.adUserData ? 'granted' : 'denied',
ad_personalization: state.adPersonalization ? 'granted' : 'denied'
});
});
// Isolated SDK consumer — init on grant, tear down on revoke.
const unsubscribe = consentBus.subscribe((state) => {
if (state.analytics) initReplaySDK();
else teardownReplaySDK();
});
// Call unsubscribe() if the component using the SDK unmounts.
// The in-house consent UI writes choices through the same public API.
document.querySelector('#accept-all')?.addEventListener('click', () => {
consentBus.setState({ analytics: true, ads: true, adUserData: true, adPersonalization: true });
});
Verification
Grant consent through the in-house UI, then revoke it, and confirm every subscriber reacts. The decisive check: open two tabs, revoke in tab A, and confirm tab B’s Network panel shows the vendor stop sending — proving the bus, its localStorage broadcast, and every subscribe callback all fired. In the console, consentBus.getState() must return the revoked state, and localStorage.getItem('consent_state_v1') must contain matching JSON with a fresh updatedAt timestamp.
Common Pitfalls
- Handing out mutable state. If
getState()returns the live object, a consumer can mutate consent without going throughsetState, and noconsentchangefires. Always return a frozen shallow copy, as above. - Leaking subscribers. Components that
subscribewithout keeping the returned unsubscribe function accumulate listeners across mounts, causing duplicate SDK init calls. Store the unsubscribe and call it on teardown. - Persisting on every cross-tab echo. If the
storagelistener re-persists the value it just received, two tabs can ping-pong writes. Broadcast the incoming change to local subscribers but avoid re-writing identical state — theupdatedAtguard or an equality check breaks the loop.