Third-party scripts execute before the CMP has resolved jurisdiction or user preferences — analytics and marketing vendors fire, data leaves the browser, and opt-out regions are violated before the consent gate has had a chance to close.

Triage Workflow: Reproduce and Confirm the Race Condition

Work through these steps in order to confirm that premature script execution is the actual failure mode rather than a misconfigured consent string or a vendor-specific bug.

Third-party script race condition timeline A horizontal timeline showing the HTML response arriving at T+0, third-party scripts firing at T+400ms, and the CMP resolving at T+1500ms — illustrating that scripts execute 1100ms before consent is known. T+0 T+400ms T+800ms T+1200ms T+1600ms HTML response 3P scripts fire CMP resolves ~1100ms data leak window Premature execution (compliance violation) Expected: scripts load only after CMP resolves

Root Cause: Two Async Boundaries That Never Synchronize

The race condition has a precise structural cause. CDN edge routers — Cloudflare, CloudFront, Fastly — inject geolocation headers (CF-IPCountry, CloudFront-Viewer-Country, Fastly-Geo-Country) into the HTTP response at the network layer. Those headers are available to server-side middleware during the request, but they vanish from the browser’s view the moment the response body begins streaming.

The client-side CMP, loaded via defer or requestIdleCallback, resolves hundreds of milliseconds later — sometimes beyond one full second on mid-range mobile hardware. Any routing middleware that defaults to a permissive allow-list to avoid blocking LCP will execute vendor scripts during this gap.

This directly violates the deterministic jurisdictional mapping that regional routing for CCPA and global privacy laws requires: the routing decision must be made against a known jurisdiction and a known consent state, not against a default assumption.

Why geo headers cannot be read client-side. CF-IPCountry and its equivalents are request headers appended by the CDN before the request reaches your origin. They never appear as response headers unless you explicitly forward them. Any JavaScript that attempts document.cookie or a client-side fetch to read geolocation is either using cached data (stale on VPN switches) or making a synchronous network round-trip that adds TTFB regression.

Why CMP events are inherently async. The architecting GDPR-compliant consent gating pattern requires the CMP to render its UI, wait for user interaction or a prior consent record lookup, and then emit tcloaded or useractioncomplete — a sequence that cannot be made synchronous without blocking the parser.

Resolution: Server-Rendered Policy + Client Promise Gate

The fix collapses both async boundaries into one deterministic pattern: resolve jurisdiction server-side (where geo headers are synchronously available), embed the result in the HTML, and combine it with the CMP’s async event on the client.

Step 1 — Inject routing policy during SSR.

In your Cloudflare Worker, Next.js middleware, or Nginx Lua layer, read the geo header and write a <meta> tag into the <head> before any scripts load:

<!-- Injected by SSR/edge middleware — never by client JS -->
<meta name="routing-policy"
      content='{"region":"US-CA","tier":"strict","requires_gpc_check":true}'>

Your middleware maps raw country/state codes to a named tier (strict, permissive, gdpr) so the client never needs to contain a country-code lookup table.

Step 2 — Read the policy synchronously on the client.

// routing-gate.js — loaded with defer in <head>

function readRoutingPolicy() {
  const el = document.querySelector('meta[name="routing-policy"]');
  if (!el) return { region: 'UNKNOWN', tier: 'strict', requires_gpc_check: false };
  try {
    return JSON.parse(el.content);
  } catch {
    // Malformed JSON — default to strictest posture
    return { region: 'UNKNOWN', tier: 'strict', requires_gpc_check: false };
  }
}

Step 3 — Resolve CMP consent state as a Promise.

function resolveConsentState(tier) {
  return new Promise((resolve) => {
    const TIMEOUT_MS = 3000;
    const deadline = Date.now() + TIMEOUT_MS;

    function poll() {
      if (tier === 'gdpr' && typeof window.__tcfapi === 'function') {
        window.__tcfapi('addEventListener', 2, (tcData, ok) => {
          if (ok && (tcData.eventStatus === 'tcloaded' ||
                     tcData.eventStatus === 'useractioncomplete')) {
            // Clean up listener to prevent memory leaks
            window.__tcfapi('removeEventListener', 2, () => {}, tcData.listenerId);
            resolve({
              framework: 'tcf',
              // Purpose 1 = store/access information on a device
              granted: tcData.purpose.consents[1] === true
            });
          }
        });
        return; // addEventListener is not a one-shot; listener above handles resolution
      }

      if ((tier === 'strict' || tier === 'permissive') &&
           typeof window.__uspapi === 'function') {
        window.__uspapi('getUSPData', 1, (uspData, ok) => {
          if (ok && uspData) {
            // 1YNN = opted out; 1YYN = opted in; 1--- = unknown
            resolve({ framework: 'usp', granted: uspData.uspString !== '1YNN' });
          } else if (Date.now() < deadline) {
            setTimeout(poll, 50);
          } else {
            // Timeout — default to opt-out
            resolve({ framework: 'usp', granted: false });
          }
        });
        return;
      }

      // Neither API present yet
      if (Date.now() < deadline) {
        setTimeout(poll, 50);
      } else {
        // Timeout with no CMP — strictest posture
        resolve({ framework: 'none', granted: false });
      }
    }

    poll();
  });
}

Step 4 — Gate all vendor injection behind both resolved values.

async function initScriptRouting() {
  const policy   = readRoutingPolicy();         // synchronous — no await
  const consent  = await resolveConsentState(policy.tier);

  // Evaluate Global Privacy Control for California (CPRA obligation)
  const gpcBlocked = policy.requires_gpc_check &&
                     navigator.globalPrivacyControl === true;

  if (!consent.granted || gpcBlocked) {
    // Optionally fire a minimal fallback — no personal-data scripts
    loadPrivacyCompliantFallbacks(policy.region);
    return;
  }

  loadApprovedVendors(policy.region, consent.framework);
}

function loadApprovedVendors(region, framework) {
  // Wire your own vendor registry here.
  // Example: dynamically append <script> tags or call a tag manager API.
  console.log(`[routing-gate] Loading vendors | region=${region} consent=${framework}`);
}

function loadPrivacyCompliantFallbacks(region) {
  // Load only analytics that operate without personal data — e.g., aggregate counters.
  console.log(`[routing-gate] Fallback mode | region=${region}`);
}

// Bootstrap: wait for DOM but do not block the parser
if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', initScriptRouting);
} else {
  initScriptRouting();
}

This gate eliminates the race condition by making the routing decision depend on a resolved Promise rather than an assumed default. It aligns with the designing graceful fallback chains for blocked scripts pattern: instead of silently failing, the gate actively loads a compliant fallback set rather than leaving analytics blind.

The CMP event contract used here (tcloaded, useractioncomplete, removeEventListener) follows the same listener lifecycle covered in how to delay third-party scripts until user consent.

Run this single check after deploying the gate:

  1. In Chrome DevTools, open the Performance panel. Record a fresh page load with cache disabled and Slow 3G throttling active.
  2. In the flame chart, locate the initScriptRouting task. Confirm it completes after the CMP addEventListener callback fires.
  3. In the Network tab, filter by your vendor domains. All requests must appear after initScriptRouting completes — any earlier requests indicate the gate is not covering all injection points.
  4. Run Lighthouse with an extended throttle config:
    {
      "throttling": {
        "rttMs": 150,
        "throughputKbps": 1638.4,
        "cpuSlowdownMultiplier": 4
      },
      "audits": ["third-party-summary", "total-blocking-time"]
    }
    The third-party-summary audit must show 0ms third-party execution before consent resolves. An LCP delta of ≤150ms above baseline is acceptable overhead for the gate itself.
  5. For California users: enable GPC in a Chromium build that supports it (or set navigator.globalPrivacyControl = true in the console), reload, and verify no vendor scripts appear in the Network waterfall.

Common Pitfalls

  • Reading geo headers client-side. CF-IPCountry is a request header, not a response header. Any client-side attempt to access it returns undefined. Derive jurisdiction server-side and pass it down as HTML metadata; never forward raw headers to the client as a response header without explicit expose-headers configuration.
  • Polling interval below 50ms. Polling __uspapi or __tcfapi at intervals shorter than 50ms adds measurable CPU pressure on mid-range Android devices and increases Total Blocking Time. The 50ms poll with a 3000ms ceiling is the minimum viable interval.
  • Applying the gate only to <script> tags and not to new Worker() or dynamic import(). Vendor scripts sometimes self-load additional workers. Ensure your vendor registry’s loadApprovedVendors function is the single entrypoint for all third-party instantiation — do not allow vendor bundles to be loaded piecemeal outside the gate.

Related

Up: Regional Routing for CCPA and Global Privacy Laws