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.

  1. 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 dataLayer push, or nothing. A mix of mechanisms is the tell.

  2. 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.

  3. 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
  4. 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.

Direct reads versus one bus On the left, four consumers each read the consent platform's global directly, so each one races the resolution independently and each must implement its own retry. On the right, the same four consumers subscribe to a single bus that owns the read, replays the last known state to late subscribers, and pushes every subsequent change. The number of race conditions drops from one per consumer to zero. direct reads · one race per consumer CMP global analytics ads chat replay each needs its own retry and its own handling for a change arriving later one bus · zero races CMP global ConsentBus owns the read · replays to late subscribers analytics ads chat replay subscribe once; correctness is the bus's problem Replay is the feature that matters: a subscriber attaching after the decision must still receive it.

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.

What the bus holds, and what a subscriber sees The bus begins in an unknown state where subscribers are registered but nothing is delivered. On the platform resolving, it moves to a resolved state holding the decision, and delivers that decision to every registered subscriber and immediately to any subscriber attaching later. On a change, it moves to an updated state, replaces the held decision and delivers it to all subscribers again. A note records that the held decision is what makes late attachment safe. unknown subscribers held, nothing sent resolve resolved decision held and delivered change updated replaces held value, re-delivers every change is just another resolve a subscriber attaching here is served immediately Holding the last value is the entire difference between a consent bus and an event emitter. Deliver asynchronously in both cases, so subscribers never observe a different ordering on attach.

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.

One-off work versus a standing obligation Two columns. One-off build work covers the state machine and replay behaviour, the storage schema and its versioning, and the banner user interface. Ongoing obligations cover keeping pace with framework specification revisions, maintaining the audit record that proves what a visitor consented to and when, and re-verifying vendor mappings whenever a vendor changes what it collects. A note records that the ongoing column, not the build, is what usually decides whether a custom platform remains viable. one-off — the part you estimated state machine, replay, subscriber list storage schema and its versioning banner UI and its accessibility measured in weeks, and then it is done ongoing — the part that decides viability tracking framework specification revisions the audit record: who consented, to what, when re-checking vendors when they change collection measured in quarters, and it never ends Teams abandon custom platforms because of the right-hand column, almost never the left. A hybrid is often best: your own bus and gate, a commercial platform for capture and the audit trail.

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 through setState, and no consentchange fires. Always return a frozen shallow copy, as above.
  • Leaking subscribers. Components that subscribe without 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 storage listener 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 — the updatedAt guard or an equality check breaks the loop.

Up: Selecting and Integrating a Consent Management Platform