Modern tag stacks fragment consent signals across a CMP, a tag management system, and a dozen isolated third-party SDKs — each with its own initialization lifecycle. When those lifecycles are not explicitly coordinated, consent state diverges: the CMP reports full denial while an analytics SDK executes, or a marketing pixel fires in a second tab where the user revoked consent moments earlier. This page addresses the exact synchronization architecture needed to eliminate that drift.

The patterns here govern a specific set of browser mechanics: the BroadcastChannel API for same-origin tab coordination, structured localStorage schemas for cross-session persistence, the IAB TCF v2.2 purpose-to-vendor mapping contract, and AbortController for in-flight request teardown on revocation. Each mechanism covers one failure surface; together they form a deterministic sync layer embedded in the broader Consent Management & Compliance Routing architecture.


Prerequisites and When to Apply

Apply this architecture when two or more of the following are true:

  • Your site initializes more than one third-party SDK that requires explicit consent (analytics, advertising, A/B testing, session replay, chat widgets).
  • Users can interact with the consent UI more than once per session — changing, upgrading, or revoking preferences mid-visit.
  • Your application spans multiple browser tabs or is a single-page app where navigation does not trigger full page reloads.
  • Your compliance scope crosses jurisdictions (EU GDPR + California CCPA), requiring different consent schemas for the same vendor pool.
When to Apply Multi-Vendor Consent Sync A decision flowchart showing four prerequisite checks that determine whether the full sync architecture is needed. 2+ consent-gated SDKs? Yes Mid-session revocation possible? No Minimal gating only Yes Multi-tab or SPA navigation? Either Full sync architecture required

Dependency on upstream patterns: This layer presupposes that GDPR-compliant consent gating is already in place — specifically that a CMP fires a structured consent event before any third-party SDK is allowed to request network resources. The sync layer described here distributes that event; it does not replace the gate itself.


Concept: The Three Failure Surfaces of Multi-Vendor Sync

Consent state diverges at three distinct points in the browser execution model:

1. Cross-tab drift. The localStorage write that persists consent in Tab A is not synchronously visible to Tab B unless Tab B explicitly reads it or a storage event propagates. Vendor SDKs that poll their own initialization flag will continue executing in Tab B even after the user revoked consent in Tab A.

2. Async race conditions. CMP callbacks are asynchronous. Tag manager listeners that fire on DOMContentLoaded may execute before the CMP’s __tcfapi('addEventListener') callback delivers the resolved consent object. The TCF specification requires CMPs to fire an addEventListener callback with eventStatus: 'tcloaded' only after the consent string is fully resolved — but many implementations fire useractioncomplete too early.

3. SDK isolation bypass. Vendor SDKs that cache their own initialization state internally (a common pattern in analytics SDKs that store a ready flag in a closure) do not respond to external consent changes after initial load. The sync layer must speak the vendor’s teardown API, not just update a shared state object.


Implementation: Building the Broadcast and Storage Layer

All state mutations flow through one canonical object shape. Versioning the schema prevents stale payload misinterpretation:

// consent-schema.js
// The canonical payload that all vendors receive.
// Version the schema so stored payloads can be migrated on upgrades.
const CONSENT_SCHEMA_VERSION = '2.2.1';

function buildConsentPayload(cmpOutput) {
  return {
    schema: CONSENT_SCHEMA_VERSION,
    timestamp: Date.now(),
    // TCF v2.2 purpose bits, keyed by integer purpose ID (1–10)
    // Values are booleans: true = consented, false = denied, null = not applicable
    purposes: cmpOutput.purposes,
    // Legitimate interest flags — separate from explicit consent
    legitimateInterests: cmpOutput.legitimateInterests,
    // IAB Global Vendor List vendor IDs mapped to boolean
    vendors: cmpOutput.vendors,
    // US Privacy or GPC signal for non-TCF jurisdictions
    region: cmpOutput.region || 'DEFAULT',
    gpc: navigator.globalPrivacyControl ?? false
  };
}

Step 2 — Establish the broadcast bus with a storage fallback

// consent-bus.js
const STORAGE_KEY = 'consent_state_v2';
let consentBus;

// BroadcastChannel is unavailable in Safari < 15.4 and some in-app WebViews.
// Fall back to storage events, which propagate across same-origin tabs automatically.
function initBus() {
  if (typeof BroadcastChannel !== 'undefined') {
    consentBus = new BroadcastChannel('consent_sync_v2');
    consentBus.onmessage = (event) => {
      const state = JSON.parse(event.data);
      applyVendorGates(state);
    };
  } else {
    // Fallback: listen for localStorage mutations from other tabs
    window.addEventListener('storage', (event) => {
      if (event.key === STORAGE_KEY && event.newValue) {
        const state = JSON.parse(event.newValue);
        applyVendorGates(state);
      }
    });
  }
}

function publishConsentState(payload) {
  const serialized = JSON.stringify(payload);

  // Notify other tabs via BroadcastChannel (if available)
  if (consentBus) {
    consentBus.postMessage(serialized);
  }

  // Persist for cross-session recovery; fall back to sessionStorage on quota error
  try {
    localStorage.setItem(STORAGE_KEY, serialized);
  } catch (e) {
    if (e instanceof DOMException && e.name === 'QuotaExceededError') {
      console.warn('[ConsentSync] localStorage quota exceeded — using sessionStorage fallback.');
      sessionStorage.setItem(STORAGE_KEY, serialized);
    }
  }
}

// Restore consent state on page load (e.g., returning visitor)
function restoreConsentState() {
  const raw = localStorage.getItem(STORAGE_KEY) || sessionStorage.getItem(STORAGE_KEY);
  if (!raw) return null;
  try {
    return JSON.parse(raw);
  } catch {
    return null;
  }
}

Step 3 — Subscribe to the CMP and drive the bus

The TCF __tcfapi interface requires registering an addEventListener callback. The callback fires multiple times; only eventStatus values of 'tcloaded' or 'useractioncomplete' represent a resolved consent object:

// cmp-bridge.js
import { buildConsentPayload, publishConsentState, initBus } from './consent-bus.js';

initBus();

// Attempt to restore a previously persisted state before the CMP resolves.
// This prevents flash-of-unblocked-script on return visits.
const restored = restoreConsentState();
if (restored) {
  applyVendorGates(restored);
}

// Subscribe to the TCF CMP. The callback fires with every consent state change.
if (typeof window.__tcfapi === 'function') {
  window.__tcfapi('addEventListener', 2, (tcData, success) => {
    if (!success) return;

    // Only act on fully resolved states — ignore 'cmpuishown' and similar
    const resolvedStatuses = ['tcloaded', 'useractioncomplete'];
    if (!resolvedStatuses.includes(tcData.eventStatus)) return;

    const payload = buildConsentPayload({
      purposes: tcData.purpose.consents,
      legitimateInterests: tcData.purpose.legitimateInterests,
      vendors: tcData.vendor.consents,
      region: document.documentElement.dataset.consentRegion || 'EU_EEA'
    });

    publishConsentState(payload);
    applyVendorGates(payload);
  });
}

TCF v2.2 purpose IDs must be translated into per-vendor initialization decisions. Fetch the vendor map dynamically from the CMP’s vendor list endpoint rather than hardcoding it, to handle onboarding and offboarding without code deploys:

// vendor-gates.js
// Purpose ID reference (IAB TCF v2.2):
// 1 = Store/access device info  |  3 = Create personalised ads profile
// 4 = Select personalised ads   |  7 = Measure ad performance
// 8 = Measure content performance

const VENDOR_GATE_MAP = {
  google_analytics: { purposes: [1, 7], liPurposes: [] },
  meta_pixel:       { purposes: [1, 3, 4], liPurposes: [] },
  hotjar:           { purposes: [1, 8], liPurposes: [8] }
};

function evaluateVendorGate(vendorId, consentState) {
  const gate = VENDOR_GATE_MAP[vendorId];
  if (!gate) return false;

  // Explicit consent check: all required purposes must be === true (not just truthy)
  const allConsented = gate.purposes.every(
    (p) => consentState.purposes[p] === true
  );

  // Legitimate interest check: LI purposes need LI flag, not consent flag
  const liSatisfied = gate.liPurposes.every(
    (p) => consentState.legitimateInterests[p] === true
  );

  return allConsented && liSatisfied;
}

export function applyVendorGates(consentState) {
  Object.keys(VENDOR_GATE_MAP).forEach((vendorId) => {
    const allowed = evaluateVendorGate(vendorId, consentState);
    if (allowed) {
      initVendor(vendorId);
    } else {
      teardownVendor(vendorId);
    }
  });
}

The Vendor Sync Flow — Architecture Diagram

Multi-Vendor Consent Sync Architecture Sequence diagram illustrating the flow from user consent action through CMP callback, canonical payload serialization, BroadcastChannel distribution, vendor gate evaluation, and SDK initialization or teardown. User CMP Consent Bus (BroadcastChannel) Vendor Gates (evaluateGate) SDKs Accept all tcloaded callback serialize + store postMessage (all tabs) Tab B receives applyVendorGates() evaluateGate() initVendor() revoke consent → teardownVendor() abort + destroy

Implementation: Regional Routing and Schema Switching

Consent schemas are jurisdiction-dependent. When regional routing for CCPA and global privacy laws is active, the sync layer must swap its evaluation logic without disrupting the vendor queue. Inject the resolved region at the edge (CDN header or server-rendered data- attribute) to avoid client-side geo-IP latency:

<!-- Server or edge middleware injects this before the consent bundle loads -->
<html lang="en" data-consent-region="US_CA">
// region-router.js
const REGION_SCHEMAS = {
  EU_EEA: {
    evaluate: evaluateTCF,        // IAB TCF v2.2 purpose bit evaluation
    defaultConsent: 'opt-in',
    gpcRequired: false
  },
  US_CA: {
    evaluate: evaluateUSPrivacy,  // CCPA / US Privacy string evaluation
    defaultConsent: 'opt-out',
    gpcRequired: true             // Sec-GPC header must be respected
  },
  DEFAULT: {
    evaluate: evaluateTCF,
    defaultConsent: 'opt-in',
    gpcRequired: false
  }
};

function getActiveSchema() {
  const region = document.documentElement.dataset.consentRegion || 'DEFAULT';
  return REGION_SCHEMAS[region] || REGION_SCHEMAS.DEFAULT;
}

function evaluateUSPrivacy(purposeId, consentState) {
  // US Privacy string: position 3 = opt-out of sale (Y = opted out)
  const usPrivacy = consentState.usPrivacy || '1---';
  const optedOut = usPrivacy[2] === 'Y';

  // Respect GPC signal as equivalent to opt-out
  if (consentState.gpc) return false;

  // If opted out of sale, block advertising purposes (purpose IDs 3, 4)
  if (optedOut && [3, 4].includes(purposeId)) return false;

  return true;
}

Handling consent revocation without a page reload requires explicit vendor lifecycle management. Maintain an AbortController registry so in-flight requests can be cancelled immediately:

// vendor-lifecycle.js
const abortRegistry = new Map();   // vendorId → AbortController
const sdkRegistry   = new Map();   // vendorId → SDK instance

export function initVendor(vendorId) {
  if (sdkRegistry.has(vendorId)) return; // already initialized

  const controller = new AbortController();
  abortRegistry.set(vendorId, controller);

  // Example: initialize analytics SDK with the abort signal
  const sdk = window[`${vendorId}_SDK`];
  if (sdk && typeof sdk.init === 'function') {
    sdk.init({ signal: controller.signal });
    sdkRegistry.set(vendorId, sdk);
  }
}

export function teardownVendor(vendorId) {
  // 1. Abort all in-flight fetch/XHR requests for this vendor
  const controller = abortRegistry.get(vendorId);
  if (controller) {
    controller.abort();
    abortRegistry.delete(vendorId);
  }

  // 2. Invoke the vendor's own cleanup API if available
  const sdk = sdkRegistry.get(vendorId);
  if (sdk && typeof sdk.destroy === 'function') {
    sdk.destroy();
  }
  sdkRegistry.delete(vendorId);

  // 3. Remove DOM nodes injected by this vendor
  document.querySelectorAll(`[data-vendor="${vendorId}"]`).forEach(el => el.remove());

  // 4. Expire vendor-scoped cookies (setting past date removes the cookie)
  document.cookie = `_${vendorId}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.${location.hostname}`;
}

Verification Checklist

Confirm correct implementation with these concrete checks:


Interaction Matrix: How This Layer Relates to Sibling Patterns

Pattern Interaction Watch for
GDPR consent gating architecture The gating layer provides the initial consent event; this layer distributes it Do not distribute events that have not yet reached tcloaded status — premature distribution causes initialization before full string resolution
Graceful fallback chains for blocked scripts Fallback chains activate when a vendor remains ungated; the sync layer must not override fallback state Gate evaluation (evaluateVendorGate) must run before fallback activation to prevent double initialization
Regional routing for CCPA and global privacy laws Regional schema selection wraps the gate evaluation function Cache the active schema reference once per page load — do not re-evaluate data-consent-region per vendor call, as DOM reads inside loops degrade INP
Script preload and prefetch Preloaded vendor scripts may complete before consent resolves Preload only the CMP and consent bundle itself — never preload vendor SDKs, as preloading forces DNS + TCP connection regardless of execution gating

Troubleshooting

Failure mode: Vendor executes in Tab B after revocation in Tab A

Symptom: Network panel in Tab B shows continued requests from a vendor domain after the user closed the consent modal in Tab A with “Reject all.”

Cause: The sync layer is using localStorage writes without a storage event listener, and BroadcastChannel was never initialized.

Fix: Confirm initBus() is called before any vendor gate evaluation. Check typeof BroadcastChannel in Tab B’s console — if undefined, verify the storage event fallback listener is registered on window.


Failure mode: tcloaded callback fires but purposes object is empty

Symptom: console.log(tcData.purpose.consents) returns {} or all values are false even after the user accepted all purposes.

Cause: The CMP is firing the callback with eventStatus: 'cmpuishown' (pre-interaction) rather than tcloaded. The consent string is not yet encoded.

Fix: Add the resolvedStatuses guard in the CMP bridge (see Step 3). Only process callbacks where eventStatus is 'tcloaded' or 'useractioncomplete'.


Failure mode: QuotaExceededError on localStorage.setItem

Symptom: Console error DOMException: QuotaExceededError when persisting consent state. Subsequent page loads treat the user as a new visitor.

Cause: Vendor SDKs storing large payloads in localStorage have consumed the 5MB quota before the consent state write.

Fix: The publishConsentState function above includes the sessionStorage fallback. Additionally, audit localStorage usage: run Object.keys(localStorage).map(k => [k, localStorage.getItem(k).length]).sort((a,b) => b[1]-a[1]) to identify the largest consumers.


Failure mode: Cookie removal on teardown does not take effect

Symptom: After teardownVendor() executes, the cookie inspector in DevTools still shows the vendor cookie present.

Cause: The domain attribute in the document.cookie delete string does not match the domain used when the cookie was set. Cookies set with domain=example.com and cookies set with domain=.example.com are distinct.

Fix: Issue the delete with both forms: once with domain=.${location.hostname} and once with domain=${location.hostname}. Also confirm path=/ matches the original cookie’s path.


Failure mode: AbortController.abort() has no effect on vendor SDK requests

Symptom: In-flight requests from the vendor continue appearing in the Network panel after teardownVendor() is called.

Cause: The vendor SDK creates its own internal XMLHttpRequest or fetch calls without accepting an external AbortSignal. The AbortController only affects requests that were initiated with the signal passed to fetch(url, { signal }).

Fix: For SDKs that do not accept abort signals, remove the SDK’s <script> tag from the DOM and overwrite the global namespace object: window[vendorId + '_SDK'] = null. This does not abort in-flight requests already dispatched by the SDK, but prevents new ones. For complete teardown, also block the vendor’s domain via a Service Worker fetchEvent handler that returns a Response with status 200 and empty body for matched URLs after revocation.


Frequently Asked Questions

Does BroadcastChannel work across all browsers I need to support?

BroadcastChannel is unsupported in Safari before 15.4 and in some in-app WebViews (Instagram, TikTok, LinkedIn). The storage event fallback covers these environments: any localStorage write in one tab fires a synchronous storage event in all other same-origin tabs. The fallback is slightly higher latency but functionally equivalent for consent propagation.

When should consent state go in sessionStorage rather than localStorage?

Use sessionStorage only as an error fallback (as shown in the publishConsentState function) or when your privacy policy requires that consent choices do not persist after the browser is closed. Storing in sessionStorage by default breaks the “returning visitor” optimization — the CMP will re-display the consent UI on every new session, causing unnecessary LCP regression from the modal render.

How do I distinguish legitimate interest from explicit consent in TCF v2.2?

TCF v2.2 encodes consent and legitimate interest in separate bit fields in the TC string. The __tcfapi callback exposes them as tcData.purpose.consents (explicit consent per purpose) and tcData.purpose.legitimateInterests (LI basis per purpose). Never treat them interchangeably: a vendor requiring explicit consent (purposes 1, 3, 4) must receive consents[purposeId] === true, not legitimateInterests[purposeId] === true.

What happens to in-flight requests when consent is revoked mid-flight?

AbortController.abort() causes any pending fetch call holding that signal to reject with an AbortError. Wrap vendor initialization calls in try/catch blocks that handle AbortError silently — it is an expected control-flow outcome, not an unexpected error. For SDKs using XMLHttpRequest, call .abort() on the XMLHttpRequest instance directly; XHR does not accept AbortSignal.


Up: Consent Management & Compliance Routing