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:
The failure is structural rather than a bug in any one call site. Direct reads couple every consumer to the timing of a decision none of them controls, and the number of broken paths grows with the number of consumers.
Replay is what separates a consent bus from a plain event emitter. Consumers attach at unpredictable times — a lazily imported chat module may subscribe seconds after the decision — and an emitter that only pushes future events leaves those consumers permanently uninitialised, which is exactly the silent failure the bus exists to remove.
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 });
});
The state a bus has to hold
A correct bus is a small state machine plus a subscriber list, and its contract is that a subscriber receives the current state immediately on attach and every change thereafter. Everything else in the implementation follows from that sentence.
Deliver on a microtask even when the value is already held. Calling a subscriber synchronously from inside subscribe() gives that one consumer a different execution ordering from every consumer notified by a later change, and code written against the synchronous case breaks the first time the decision arrives in the other order.
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.
What you take on by building it yourself
A custom bus is a reasonable choice, but it is worth being explicit about which responsibilities move onto your team when you decline a commercial platform. Three of them are ongoing rather than one-off, and they are the ones that decide whether the build stays viable in two years.
The hybrid is worth considering before committing either way. Building the bus and the gate in-house gives you the timing control and the first-party bundle you wanted, while a commercial platform behind it keeps the certification, the audit record, and the regulatory tracking someone else’s problem — and the adapter boundary means swapping that platform later is a single module.
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.