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.
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.
Verification: Confirm Zero Pre-Consent Execution
Run this single check after deploying the gate:
- In Chrome DevTools, open the Performance panel. Record a fresh page load with cache disabled and Slow 3G throttling active.
- In the flame chart, locate the
initScriptRoutingtask. Confirm it completes after the CMPaddEventListenercallback fires. - In the Network tab, filter by your vendor domains. All requests must appear after
initScriptRoutingcompletes — any earlier requests indicate the gate is not covering all injection points. - Run Lighthouse with an extended throttle config:
The{ "throttling": { "rttMs": 150, "throughputKbps": 1638.4, "cpuSlowdownMultiplier": 4 }, "audits": ["third-party-summary", "total-blocking-time"] }third-party-summaryaudit must show 0ms third-party execution before consent resolves. An LCP delta of ≤150ms above baseline is acceptable overhead for the gate itself. - For California users: enable GPC in a Chromium build that supports it (or set
navigator.globalPrivacyControl = truein the console), reload, and verify no vendor scripts appear in the Network waterfall.
Common Pitfalls
- Reading geo headers client-side.
CF-IPCountryis a request header, not a response header. Any client-side attempt to access it returnsundefined. Derive jurisdiction server-side and pass it down as HTML metadata; never forward raw headers to the client as a response header without explicitexpose-headersconfiguration. - Polling interval below 50ms. Polling
__uspapior__tcfapiat 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 tonew Worker()or dynamicimport(). Vendor scripts sometimes self-load additional workers. Ensure your vendor registry’sloadApprovedVendorsfunction is the single entrypoint for all third-party instantiation — do not allow vendor bundles to be loaded piecemeal outside the gate.
Related