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.
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
Step 1 — Define the canonical consent payload schema
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);
});
}
Step 4 — Map vendor APIs to unified consent gates
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
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;
}
Implementation: Teardown on Consent Revocation
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.