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:

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