GDPR consent gating solves a concrete browser problem: by the time a user sees your CMP banner, the browser has already resolved DNS, opened TCP connections, and in many cases transmitted data to analytics and ad-tech vendors. A compliant architecture must function as a network-level circuit breaker — not a visual overlay that hides scripts while they silently execute. This means intercepting script elements before they reach the parser, resolving consent state before any external payload is fetched, and synchronising that state deterministically across tabs, SPA route transitions, and session boundaries.

The patterns below address the full stack: edge-layer geo-scoping, a reactive consent state machine, hard script-execution isolation, and automated validation. Each builds on the broader consent management and compliance routing compliance topology that governs policy enforcement across micro-frontends and vendor ecosystems.


Prerequisites and When to Apply This Architecture

This architecture applies when all three conditions are true:

  1. Your site loads cross-origin vendor scripts (analytics, advertising, A/B testing, heatmaps, chat) that make independent network requests.
  2. You serve traffic from EU/EEA countries, making GDPR Article 6(1)(a) lawful basis a legal requirement.
  3. A Consent Management Platform (CMP) implementing IAB TCF v2.2 is in use, or you are building a custom consent layer.

Decision criteria:

Consent Gating Decision Tree A flowchart guiding engineers through whether full GDPR consent gating applies to their site, based on third-party scripts, EU traffic, and CMP availability. Loads third-party scripts? No No gating needed Yes Serves EU/EEA traffic? No Lightweight CCPA routing only Yes TCF v2.2 CMP in place? No Build consent capture first Yes Apply full consent gate architecture (this page)

Mandatory baseline requirements:


Concept: What the Browser Does Without a Gate

Understanding the browser’s default behaviour reveals exactly why a visual overlay is insufficient for compliance.

When the HTML parser encounters a <script src="..."> element, it immediately queues a DNS lookup and TCP connection — regardless of async or defer. By the time your CMP banner renders (typically 80–400ms into the page load, depending on CMP bundle size), the browser has already:

  1. Resolved DNS for each vendor domain listed in <link rel="preconnect"> or <link rel="dns-prefetch"> tags
  2. Opened TLS connections to those origins
  3. In many configurations, fetched the vendor SDK itself from cache or CDN

The TCF v2.2 specification addresses this through the __tcfapi interface: vendors must call addEventListener on the CMP API and wait for a tcloaded event with gdprApplies: true and valid purpose consents before processing any data. However, vendor SDKs that auto-initialize on load do not honour this contract unless you prevent them from loading in the first place.

The compliant model: treat every third-party script element as a pending promise. Only resolve that promise — i.e., inject the real <script src> into the DOM — after consent flags are confirmed for the relevant TCF purpose categories.


Implementation

Step 1 — Establish Deny-by-Default Script Markup

Replace all vendor <script> elements with inert placeholders. The browser will not fetch or execute elements with type="text/plain", and the data- attributes carry the metadata needed for the consent gate to reconstruct them.

<!-- Before: immediately fetches and executes on parse -->
<script src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXXX" async></script>

<!-- After: browser ignores this element entirely -->
<script
  type="text/plain"
  data-src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXXX"
  data-consent-category="analytics"
></script>

<!-- Marketing / advertising vendor -->
<script
  type="text/plain"
  data-src="https://connect.facebook.net/en_US/fbevents.js"
  data-consent-category="marketing"
></script>

Apply the same pattern to <link rel="preconnect"> and <link rel="dns-prefetch"> elements — they trigger network activity even when the target script never loads.

Step 2 — Edge-Based Geo-Scoping

Client-side IP resolution adds 200–800ms of latency, breaks under corporate proxies, and fragments CDN cache keys. Jurisdiction evaluation belongs at the CDN or edge layer. The worker reads a trusted header (cf-ipcountry on Cloudflare Workers, x-vercel-ip-country on Vercel Edge), injects a deterministic region flag, and sets Vary so the CDN caches GDPR and non-GDPR responses separately.

Integration with non-EU jurisdictions should reference regional routing for CCPA and global privacy laws to avoid duplicating this routing logic for California, Brazil, or Canadian visitors.

// Edge Middleware — Cloudflare Worker / Vercel Edge (agnostic)
export async function handleRequest(req) {
  const country =
    req.headers.get('cf-ipcountry') ||
    req.headers.get('x-vercel-ip-country') ||
    'XX'; // Default to gating on unknown origin

  const euCountries = new Set([
    'AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR',
    'HU','IE','IT','LV','LT','LU','MT','NL','PL','PT','RO','SK','SI','ES','SE',
    'IS','LI','NO' // EEA non-EU members
  ]);

  const consentRegion = euCountries.has(country) ? 'GDPR' : 'NON_GDPR';

  const response = await fetch(req);
  const res = new Response(response.body, response);
  res.headers.set('x-consent-region', consentRegion);
  // Separate CDN cache slots for GDPR vs non-GDPR responses
  res.headers.append('Vary', 'x-consent-region');

  return res;
}

The injected x-consent-region header is read by the client-side consent bootstrap to skip the CMP entirely for non-applicable visitors, preserving both UX and LCP for the majority of global traffic.

Consent state must be managed through a reactive pub/sub architecture. Polling the DOM or CMP UI for state changes blocks the main thread and creates race conditions during SPA navigation.

The state machine parses the IAB TCF string into per-category boolean flags using the __tcfapi interface. BroadcastChannel synchronises the resolved state across open tabs; a CustomEvent allows in-page listeners to hydrate immediately without a second API call. For enterprise vendor ecosystems with complex propagation requirements, see syncing consent states across multiple vendors.

// consent-state-machine.js
const CONSENT_STATE = {
  channel: new BroadcastChannel('consent_sync'),

  // TCF v2.2 purpose IDs mapped to internal category names
  // Purpose 1 = store/access device info (analytics)
  // Purpose 2 = basic ads
  // Purpose 4 = personalised ads
  purposes: { analytics: 1, functional: 2, marketing: 4 },

  async resolveState() {
    // Serve cached flags while CMP API initialises (max 24h staleness)
    const cached = JSON.parse(localStorage.getItem('consent_flags') || '{}');
    if (cached.timestamp && Date.now() - cached.timestamp < 86_400_000) {
      return cached.flags;
    }

    if (!window.__tcfapi) {
      // No CMP present — treat as withdrawn consent (deny by default)
      return Object.fromEntries(Object.keys(this.purposes).map(k => [k, false]));
    }

    return new Promise((resolve) => {
      window.__tcfapi('getTCData', 2, (tcData, success) => {
        if (!success || !tcData.gdprApplies) {
          return resolve(Object.fromEntries(Object.keys(this.purposes).map(k => [k, false])));
        }

        const flags = {};
        for (const [category, purposeId] of Object.entries(this.purposes)) {
          // Require explicit positive consent — do not infer from legitimate interest
          flags[category] = tcData.purpose.consents[purposeId] === true;
        }

        // Persist with version stamp so consent changes invalidate cache
        localStorage.setItem('consent_flags', JSON.stringify({
          flags,
          timestamp: Date.now(),
          policyVersion: tcData.policyVersion
        }));

        this.channel.postMessage({ type: 'CONSENT_UPDATE', flags });
        resolve(flags);
      });
    });
  },

  subscribe(callback) {
    // Cross-tab updates via BroadcastChannel
    this.channel.addEventListener('message', (e) => {
      if (e.data.type === 'CONSENT_UPDATE') callback(e.data.flags);
    });
    // In-page updates dispatched by the CMP callback
    window.addEventListener('consent:state', (e) => callback(e.detail));
  }
};

Step 4 — Network Isolation via MutationObserver Script Gate

The MutationObserver intercepts every <script type="text/plain"> element added to the DOM — including those injected by tag managers — and holds them in an execution queue until consent flags resolve. This prevents premature browser prefetching and eliminates unauthorized data transmission, even when vendor code attempts self-injection.

The hydrateScripts function dynamically reconstructs legitimate <script> elements only for categories that have confirmed consent. For step-by-step vendor SDK integration patterns, see how to delay third-party scripts until user consent.

// consent-gate.js
const scriptQueue = new Map(); // src → { category, node, executed }

const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node.nodeName !== 'SCRIPT') continue;
      if (node.type !== 'text/plain') continue;

      const src = node.getAttribute('data-src');
      const category = node.getAttribute('data-consent-category');
      if (!src || !category) continue;

      scriptQueue.set(src, { category, node, executed: false });
      node.remove(); // Prevent any default execution or prefetch
    }
  }
});

observer.observe(document.documentElement, { childList: true, subtree: true });

// Call once consent flags resolve — safe to call multiple times (idempotent)
function hydrateScripts(consentFlags) {
  for (const [src, config] of scriptQueue.entries()) {
    if (config.executed) continue;
    if (!consentFlags[config.category]) continue;

    const script = document.createElement('script');
    script.src = src;
    script.async = true;

    // Preserve any data attributes from the original placeholder
    for (const attr of config.node.attributes) {
      if (attr.name.startsWith('data-') && attr.name !== 'data-src' && attr.name !== 'data-consent-category') {
        script.setAttribute(attr.name, attr.value);
      }
    }

    document.head.appendChild(script);
    config.executed = true;
  }
}

// Wire together: resolve state, then hydrate
(async () => {
  const flags = await CONSENT_STATE.resolveState();
  hydrateScripts(flags);
  CONSENT_STATE.subscribe(hydrateScripts); // Re-run on consent updates
})();

The CMP bundle itself must not block rendering. Load it with defer (not async, to preserve deterministic ordering relative to DOMContentLoaded) and attach the __tcfapi stub as a synchronous inline script that queues API calls before the full CMP loads. Reserve banner DOM space with min-height and contain: layout to prevent Cumulative Layout Shift when the banner renders.

<!-- Inline TCF stub — synchronous, minimal, no network request -->
<script>
  window.__tcfapi = window.__tcfapi || function(cmd, version, callback) {
    window.__tcfapi.queue = window.__tcfapi.queue || [];
    window.__tcfapi.queue.push([cmd, version, callback]);
  };
</script>

<!-- CMP bundle: defer preserves execution order, does not block parser -->
<script src="/cmp/cmp-bundle.min.js" defer></script>

<!-- Reserve space for consent banner — prevents layout shift -->
<style>
  #cmp-banner {
    min-height: 80px;        /* match your actual banner height */
    contain: layout;         /* isolate layout reflow to this element */
  }
</style>

Verification Checklist

Run these checks in order after implementing the consent gate. Each item is reproducible without specialised tooling.


Interaction Matrix

How this architecture’s patterns interact with adjacent compliance and performance techniques:

Pattern Interaction Guidance
CSP script-src directive CSP can block dynamically injected scripts regardless of consent state — allowlist vendor domains in script-src to prevent CSP violations after consent Allowlist only post-consent vendor origins; use nonce- for the inline TCF stub
Graceful fallback chains When consent is denied, blocked scripts need fallback logic (placeholder UI, degraded analytics) Define fallback handlers in hydrateScripts for categories with flags[category] === false
async / defer loading order The CMP bundle must load before any vendor SDK; defer preserves DOM order, async does not Use defer for the CMP bundle; use dynamic injection (not async) for all vendor SDKs
Network waterfall optimisation Pre-consent <link rel="preconnect"> for vendor domains leaks intent — remove them from <head> and add them dynamically at hydration time Add preconnect hints inside hydrateScripts just before injecting the corresponding <script>
Regional routing Non-EU visitors skip GDPR gating but may need CCPA opt-out routing Read the x-consent-region header client-side and branch between full gate and lightweight opt-out UI

Troubleshooting

Symptom: DevTools Network shows requests to analytics.google.com, connect.facebook.net, or similar during the 0–400ms window before the CMP UI appears.

Cause: The vendor script is loaded with a real src attribute (not type="text/plain"), so the browser fetches it during HTML parse.

Fix: Convert the <script> element to a type="text/plain" placeholder. Also audit <link rel="preconnect"> and <link rel="dns-prefetch"> for vendor domains — remove them from <head> and inject them dynamically inside hydrateScripts.


Failure mode: __tcfapi returns success: false on first call

Symptom: The getTCData callback fires with success === false, causing resolveState() to fall back to empty flags.

Cause: The CMP has not finished initialising when resolveState() is called. This is common when the CMP bundle loads async instead of defer.

Fix: Switch the CMP <script> tag to defer. As a belt-and-braces measure, retry the __tcfapi call using addEventListener instead of getTCDataaddEventListener queues inside the CMP until it is ready, then replays. For structured diagnosis, follow debugging CMP integration failures with analytics tags.

// Robust alternative: addEventListener replays after CMP loads
window.__tcfapi('addEventListener', 2, (tcData, success) => {
  if (success && tcData.eventStatus === 'tcloaded') {
    // CMP fully initialised — safe to resolve flags
    resolveFromTCData(tcData);
  }
});

Symptom: After navigating between pages in a React/Vue/Next.js SPA, the consent banner reappears or vendor scripts fire as if consent were never granted.

Cause: The consent bootstrap script re-runs on route change, clearing in-memory state. localStorage is present but the bootstrap reads it before the CMP API resolves, getting stale data.

Fix: Lift consent state into a module-level singleton that persists across route changes. Gate re-initialisation on the policyVersion stored in localStorage matching the current CMP policy version, not on session start alone.


Failure mode: Cumulative Layout Shift spike when banner renders

Symptom: Lighthouse or the Layout Shift API reports CLS > 0.1 correlated with the consent banner appearing.

Cause: The banner pushes existing page content down because no space was reserved for it in the initial layout.

Fix: Add min-height: <banner-height>px; contain: layout to the banner container in static CSS. Use position: fixed if the banner overlays content rather than pushing it; fixed elements do not contribute to layout shift.


Failure mode: BroadcastChannel messages not received in iOS Safari

Symptom: Cross-tab consent sync works in Chrome and Firefox but not Safari on iOS 15 and earlier.

Cause: BroadcastChannel was not supported in Safari until version 15.4 (March 2022). Older iOS devices silently fail.

Fix: Feature-detect and fall back to localStorage event polling for cross-tab sync:

const channel = typeof BroadcastChannel !== 'undefined'
  ? new BroadcastChannel('consent_sync')
  : null;

if (!channel) {
  // Fallback: poll localStorage for changes from other tabs
  window.addEventListener('storage', (e) => {
    if (e.key === 'consent_flags' && e.newValue) {
      const { flags } = JSON.parse(e.newValue);
      hydrateScripts(flags);
    }
  });
}

Frequently Asked Questions

Does setting async or defer on a script prevent pre-consent network requests?

No. Both attributes control execution timing, not resource fetching. The browser issues the GET request as soon as the <script src> element is parsed. The only way to prevent a network request is to prevent the element from entering the DOM entirely — use type="text/plain" with a MutationObserver interceptor, or create the <script> element dynamically in JavaScript only after consent is confirmed.

Can I gate scripts server-side to eliminate client-side race conditions?

Edge workers can strip or conditionally inject <script> tags per jurisdiction, which prevents accidental loading for applicable regions. However, the individual visitor’s opt-in or opt-out decision is not known until the browser receives and responds to the CMP prompt. A client-side gate remains necessary. Use the server side to handle geo-scoping and to strip vendor preconnects; use the client side to gate on the actual consent choice.

What happens to consent state during SPA route transitions?

Client-side routers do not reload the page, so load/unload listeners do not fire. Attach consent hydration to popstate and intercept history.pushState to detect route changes. Store resolved flags in a module-level singleton so they survive navigation without re-querying the CMP API. Use BroadcastChannel to resync state across tabs open before the navigation occurred.

How should I handle vendors whose SDKs auto-initialize on load?

Never let an auto-initialising SDK reach the DOM before consent. The type="text/plain" + MutationObserver pattern prevents the element from loading. For SDKs injected by a tag manager (e.g. GTM), configure the tag’s firing trigger to depend on a custom consent event (consent:state) rather than DOM Ready or Page View. This shifts the gate into the tag manager layer without modifying the SDK itself.


Performance Impact Reference

Dimension Target Validation method
Pre-consent network requests 0 DevTools Network — filter by vendor domain
LCP improvement from deferred bundles +15–30% WebPageTest filmstrip comparison
TTFB stability across consent states Flat (edge cache Vary correct) CDN analytics, response header audit
TCF v2.2 string compliance 100% across all purpose IDs IAB TCF Validator, CMP audit logs
Unauthorized pre-consent transmission 0 bytes Playwright network assertions
Consent banner render time < 100ms PerformanceObserverfirst-contentful-paint
CLS during post-consent script injection < 0.1 Layout Shift API, Chrome UX Report
Cross-tab state sync latency < 50ms BroadcastChannel message timing

Up: Consent Management & Compliance Routing