Third-party scripts do not wait for lawyers. Without deliberate infrastructure, an analytics pixel or ad-tech tag fires the instant the browser parses a <script> tag — regardless of whether the visitor is in California, Germany, or Virginia, and regardless of what any privacy law requires of that moment. The browser does not know which jurisdiction applies; your servers do.
This page covers jurisdiction-aware script routing: resolving the applicable privacy rule at the network edge, encoding that decision as an execution policy, and ensuring every downstream tag receives and respects that policy before it makes a single outbound network request. Getting this right eliminates the most common class of CCPA and GDPR compliance violations for script-heavy sites and, as a direct side effect, measurably reduces main-thread contention for visitors in strictly regulated regions.
Prerequisites and When to Apply This Pattern
Apply jurisdiction-aware routing when any of these are true:
- Your site loads third-party scripts (analytics, ad tech, marketing automation, A/B testing) that initiate outbound network requests or read/write cookies or local storage.
- You serve traffic from both opt-in jurisdictions (EU/EEA, UK) and opt-out jurisdictions (California, Virginia, Colorado, Connecticut, Texas) simultaneously.
- Your current consent implementation runs entirely client-side, meaning tags can fire during the CMP initialisation window before a routing decision is available.
- You have received a CCPA “Do Not Sell/Share” request and need to verify that the technical enforcement path actually blocks the relevant scripts.
You do not need this pattern if you serve a single jurisdiction, have no third-party scripts that exfiltrate personal data, or already enforce all routing at the tag-management layer with verified zero-fire guarantees before CMP resolution.
Decision checklist before implementation:
The Opt-In/Opt-Out Divide: GDPR vs. CCPA vs. State Laws
The most operationally significant split in global privacy law is the consent paradigm: opt-in versus opt-out. Every routing rule you write descends from this distinction.
GDPR (EU/EEA, UK GDPR): Scripts that process personal data require explicit, freely-given, specific consent before execution. The default state is denied. Tags must remain completely dormant — no DNS prefetch, no preconnect, no speculative load — until the user grants consent for the relevant category.
CCPA/CPRA (California): Scripts may execute immediately. The framework does not require prior consent; it requires the ability to opt out. If a user has sent Sec-GPC: 1 in their request headers, or has explicitly opted out via your CMP’s “Do Not Sell or Share My Personal Information” mechanism, scripts that sell or share personal data must stop. The default state is granted.
Virginia CDPA, Colorado CPA, Connecticut CTDPA, Texas TDPSA, and others: These mirror the opt-out model but with variations in the categories they cover, the definition of “sensitive data,” and whether Sec-GPC is treated as a legally-valid opt-out signal. Virginia and Colorado do not mandate Sec-GPC recognition; California does under CPRA.
This produces a three-tier routing matrix:
The routing matrix in code form, covering the jurisdictions with the highest traffic significance:
{
"routing_matrix": {
"EU": {
"default_consent": "denied",
"execution_mode": "lazy",
"requires_gpc_check": false,
"legal_basis": "GDPR Art.6(1)(a)"
},
"US-CA": {
"default_consent": "granted",
"execution_mode": "immediate",
"requires_gpc_check": true,
"legal_basis": "CCPA/CPRA"
},
"US-VA": {
"default_consent": "granted",
"execution_mode": "immediate",
"requires_gpc_check": false,
"legal_basis": "Virginia CDPA"
},
"US-CO": {
"default_consent": "granted",
"execution_mode": "immediate",
"requires_gpc_check": false,
"legal_basis": "Colorado CPA"
},
"US-TX": {
"default_consent": "granted",
"execution_mode": "immediate",
"requires_gpc_check": false,
"legal_basis": "Texas TDPSA"
},
"DEFAULT": {
"default_consent": "denied",
"execution_mode": "lazy",
"requires_gpc_check": false,
"legal_basis": "conservative-fallback"
}
}
}
The DEFAULT policy uses a conservative denied state. This is intentional: an unknown jurisdiction should never silently execute data-exfiltrating scripts.
Implementation: Building the Edge Routing Engine
The routing engine lives entirely in CDN edge middleware. Resolving jurisdiction server-side means zero main-thread impact — the decision is complete before the browser receives the first byte of HTML. This is the core advantage over client-side geo-IP lookups, which block the main thread and introduce latency directly into Time to First Byte.
Step 1: Read the geo header and resolve the routing policy
// Cloudflare Workers — runs at every PoP before the origin request
export default {
async fetch(request, env) {
// CF-IPCountry is injected by Cloudflare's infrastructure; never trust a client-sent version
const country = request.headers.get('CF-IPCountry') || 'XX';
const region = request.headers.get('CF-IPRegion') || ''; // e.g. "CA" for California
const policy = resolvePolicy(country, region);
// Fetch origin HTML
const originResponse = await fetch(request);
const response = new Response(originResponse.body, originResponse);
// Pass the resolved policy downstream as a header
// The client reads this from a <meta> tag injected by the HTML rewriter (Step 2)
response.headers.set('X-Routing-Policy', JSON.stringify(policy));
// Critical: partition the CDN cache per country so a California-optimised response
// is never served to an EU visitor or vice versa
response.headers.append('Vary', 'CF-IPCountry, CF-IPRegion');
return response;
}
};
function resolvePolicy(country, region) {
const EU = 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'
]);
if (EU.has(country) || country === 'GB') {
return { default_consent: 'denied', execution_mode: 'lazy', requires_gpc_check: false };
}
if (country === 'US') {
// California: CCPA + GPC recognition is legally mandated
if (region === 'CA') {
return { default_consent: 'granted', execution_mode: 'immediate', requires_gpc_check: true };
}
// Virginia, Colorado, Texas, Connecticut: opt-out, no GPC mandate
if (['VA','CO','TX','CT'].includes(region)) {
return { default_consent: 'granted', execution_mode: 'immediate', requires_gpc_check: false };
}
// All other US states: follow conservative default until their laws are enacted
return { default_consent: 'granted', execution_mode: 'immediate', requires_gpc_check: false };
}
// Unknown jurisdiction: conservative deny
return { default_consent: 'denied', execution_mode: 'lazy', requires_gpc_check: false };
}
Step 2: Inject the policy into the HTML response
Use an HTMLRewriter to embed the resolved policy as a <meta> tag in the document <head>. This avoids an additional fetch round-trip from the client to retrieve its own jurisdiction.
// Extend the fetch handler above — wrap originResponse with an HTMLRewriter
async fetch(request, env) {
const country = request.headers.get('CF-IPCountry') || 'XX';
const region = request.headers.get('CF-IPRegion') || '';
const policy = resolvePolicy(country, region);
const originResponse = await fetch(request);
const rewritten = new HTMLRewriter()
.on('head', {
element(el) {
// Insert as the first child of <head> so it is available before any script runs
el.prepend(
`<meta name="routing-policy" content='${JSON.stringify(policy)}'>`,
{ html: true }
);
}
})
.transform(originResponse);
rewritten.headers.append('Vary', 'CF-IPCountry, CF-IPRegion');
return rewritten;
}
Step 3: Read the policy client-side and gate script execution
// Must run before any third-party <script> tag. Place inline in <head> immediately
// after the <meta name="routing-policy"> tag.
(function applyRoutingPolicy() {
const meta = document.querySelector('meta[name="routing-policy"]');
if (!meta) {
// No policy resolved — apply conservative deny
window.__routingPolicy = { default_consent: 'denied', execution_mode: 'lazy', requires_gpc_check: false };
return;
}
const policy = JSON.parse(meta.content);
// California: honour the Global Privacy Control signal
// navigator.globalPrivacyControl is true when the browser sends Sec-GPC: 1
if (policy.requires_gpc_check && navigator.globalPrivacyControl === true) {
policy.default_consent = 'denied';
policy.execution_mode = 'lazy';
}
window.__routingPolicy = policy;
// Initialise GTM Consent Mode v2 BEFORE the GTM container script tag loads.
// This must happen synchronously so GTM reads the correct initial state.
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('consent', 'default', {
ad_storage: policy.default_consent === 'granted' ? 'granted' : 'denied',
analytics_storage: policy.default_consent === 'granted' ? 'granted' : 'denied',
ad_user_data: policy.default_consent === 'granted' ? 'granted' : 'denied',
ad_personalization: policy.default_consent === 'granted' ? 'granted' : 'denied',
functionality_storage: 'granted', // typically permitted without consent
security_storage: 'granted'
});
})();
Step 4: Dynamic script loader for non-GTM tags
For scripts loaded outside a tag manager, route them through a policy-aware loader:
/**
* Load a third-party script respecting the resolved routing policy.
* Call this function instead of creating <script> tags directly.
*
* @param {string} src - The script URL
* @param {string} purpose - 'analytics' | 'advertising' | 'personalisation' | 'functional'
*/
function loadThirdParty(src, purpose) {
const policy = window.__routingPolicy;
if (!policy) {
console.warn('[routing] Policy not initialised — script blocked:', src);
return;
}
// Functional scripts are allowed regardless of consent regime
if (purpose !== 'functional' && policy.execution_mode === 'lazy' && policy.default_consent === 'denied') {
// Queue for post-consent injection if a CMP is present
document.addEventListener('consent:granted:' + purpose, () => loadThirdParty(src, purpose), { once: true });
return;
}
const script = document.createElement('script');
script.src = src;
if (policy.execution_mode === 'immediate') {
// async: non-blocking, eager network request — appropriate for opt-out regions
script.async = true;
} else {
// defer: non-blocking but waits for DOM parse — appropriate for functional scripts in deny regions
script.defer = true;
}
document.head.appendChild(script);
}
Propagating the Decision to Third-Party Vendors
Once the routing policy is resolved and applied to Consent Mode v2 (Step 3 above), GTM-managed tags automatically inherit the correct state. Tags that fire under ad_storage: denied send cookieless pings; tags that fire under analytics_storage: denied omit user-identifying parameters.
For vendors outside GTM — particularly those using the IAB Global Privacy Platform (GPP) — you need to expose the correct GPP string. The IAB US Privacy String (USP, exposed via __uspapi) was sunset in January 2024; new integrations must target GPP:
// GPP US National section — generated after routing policy is known
// Requires @iabtechlab/gpp-string or equivalent library
import { GPPString } from '@iabtechlab/gpp-string';
function buildGPPString(policy, userOptOut) {
const usNational = {
// SharingNotice: 1 = notice given, 0 = not given
SharingNotice: 1,
// SaleOptOutNotice: 1 = opt-out opportunity provided
SaleOptOutNotice: policy.requires_gpc_check ? 1 : 0,
// SaleOptOut: 1 = user has opted out
SaleOptOut: userOptOut ? 1 : 0,
// MspaServiceProviderMode: 0 = not applicable
MspaServiceProviderMode: 0
};
return GPPString.encode({ sections: { usNat: usNational } });
}
// Expose for vendor polling — must be available before vendor scripts initialise
window.__gpp = function(command, callback, parameter) {
if (command === 'ping') {
callback({ gppVersion: '1.1', applicableSections: [7] }, true);
}
if (command === 'getGPPData') {
callback({ gppString: buildGPPString(window.__routingPolicy, window.__userOptedOut) }, true);
}
};
For cross-vendor synchronisation and handling the edge case where a user revokes consent after page load, see syncing consent states across multiple vendors.
Verification Checklist
Run these checks after deploying the routing layer. Each item is independently verifiable without production traffic.
Interaction Matrix: How This Pattern Fits with Sibling Approaches
| Pattern | Interaction | Notes |
|---|---|---|
| GDPR consent gating | Shares the routing matrix; GDPR regions need the additional CMP opt-in flow on top of the edge deny | Run both: edge routing sets the safe default, CMP UI handles explicit grant |
| Graceful fallback chains | Blocked scripts in EU regions need fallback behaviours (placeholder UI, server-side analytics) | The loadThirdParty() function above fires consent:granted:* events that fallback chains can listen on |
| Mapping regional laws to routing rules | Provides the legal threshold detail for each jurisdiction in the routing matrix above | Consult when adding new US state laws or updating existing policy objects |
Script loading with async/defer |
Opt-out regions permit async loading; opt-in regions should use defer or dynamic injection post-consent |
Mixing async and the routing loader can cause double-load; ensure the loader is the single source of truth |
| CDN cache partitioning | Vary header fragmentation increases cache miss rate for low-traffic regions |
Monitor CDN cache hit ratio per-region; accept higher misses for EU as the compliance cost |
Troubleshooting
Failure: EU visitors see non-consent scripts firing
Symptom: Network tab shows requests to analytics.google.com or ad-tech domains on page load for EU IPs, before any CMP interaction.
Cause: Either the CF-IPCountry header is absent (the edge function is not running), the Vary header is missing and a cached non-EU response is being served, or the GTM Consent Mode default is initialised after the GTM container loads.
Fix: Confirm the edge function is deployed and active for the target hostname. Add console.log(request.headers.get('CF-IPCountry')) in the Worker to verify. Check the Vary header on the cached response. Ensure the gtag('consent', 'default', ...) call is strictly before the <script src="gtm.js"> tag in the HTML.
Failure: California visitors are being blocked unnecessarily
Symptom: Users in CA report that analytics features, cookie preferences, or personalisation do not work even when they have not opted out.
Cause: The routing policy is incorrectly inheriting the EU denied default for California traffic. This happens when the CF-IPRegion header is not being read, leaving the engine to match country === 'US' without region refinement and falling through to the conservative default.
Fix: Add explicit logging of CF-IPRegion in the Worker. Confirm the CDN product plan includes region-level headers (some plans expose only country). If CF-IPRegion is unavailable, use a MaxMind or ip-api lookup at edge to resolve the sub-national region.
Failure: GPC signal is ignored for California traffic
Symptom: navigator.globalPrivacyControl returns true in the browser console, but scripts that sell or share personal data continue to fire.
Cause: The client-side policy reader is not checking navigator.globalPrivacyControl, or it runs after the tags have already initialised. GPC must be evaluated before any tag fires, not in a DOMContentLoaded handler.
Fix: Move the GPC check into the synchronous inline script in <head> (Step 3 above). Verify with console.log(navigator.globalPrivacyControl) in the browser console immediately after page load — it should be true when GPC is active.
Failure: Inconsistent routing across CDN PoPs
Symptom: The same user IP receives different routing policies on repeated loads, or A/B testing shows some EU users receiving immediate-execution policies.
Cause: Missing or incorrectly set Vary header, causing the CDN to serve the same cached response regardless of CF-IPCountry. A cached US-optimised response served to an EU visitor bypasses all routing logic.
Fix: Confirm Vary: CF-IPCountry, CF-IPRegion is present on every document response. Check the CDN dashboard for cache hit ratio per country — EU should show lower hit rates if Vary is correctly partitioning the cache.
Failure: Performance budget exceeded in EU despite blocking scripts
Symptom: Lighthouse or RUM shows high TBT and late LCP for EU visitors, even though third-party scripts are blocked.
Cause: The consent UI (cookie banner) itself is often a heavy third-party script. CMP SDKs from vendors like OneTrust or Cookiebot can be 60–150 KB of synchronously evaluated JavaScript. The routing engine blocks analytics but the CMP payload still fires on every EU page load.
Fix: Load the CMP asynchronously with defer where legally permissible. Audit the CMP bundle size. Consider a lightweight custom CMP for EU traffic rather than a full-featured SDK, and defer non-essential CMP features (preference centre UI) until the user actively requests them. For detailed delay patterns, see how to delay third-party scripts until user consent.
Automated Testing with Regional Proxies
Relying on production traffic to verify compliance is not sufficient. Use automated headless tests that simulate each jurisdiction’s geo headers:
// Playwright test — verifies EU routing blocks analytics scripts
import { chromium } from 'playwright';
import { expect } from '@playwright/test';
test('EU routing: no analytics requests before consent', async ({ page }) => {
const analyticsRequests = [];
// Intercept all outbound network requests
page.on('request', request => {
const url = request.url();
if (url.includes('analytics.google.com') || url.includes('doubleclick.net')) {
analyticsRequests.push(url);
}
});
// Simulate EU IP by overriding the geo header via a proxy or test server
// that injects CF-IPCountry: DE into the request headers
await page.goto('https://staging.yoursite.com', {
extraHTTPHeaders: { 'CF-IPCountry': 'DE' }
});
// Wait for full page load including async scripts
await page.waitForLoadState('networkidle');
// Assert zero analytics network requests before any consent action
expect(analyticsRequests.length).toBe(0);
});
test('California with GPC: analytics blocked', async ({ page }) => {
const analyticsRequests = [];
page.on('request', request => {
if (request.url().includes('analytics.google.com')) analyticsRequests.push(request.url());
});
// Override GPC via CDP
const client = await page.context().newCDPSession(page);
await client.send('Emulation.setUserAgentOverride', {
userAgent: page.context().browser().version(),
userAgentMetadata: {}
});
// Inject navigator.globalPrivacyControl = true before page scripts run
await page.addInitScript(() => {
Object.defineProperty(navigator, 'globalPrivacyControl', { value: true, writable: false });
});
await page.goto('https://staging.yoursite.com', {
extraHTTPHeaders: { 'CF-IPCountry': 'US', 'CF-IPRegion': 'CA' }
});
await page.waitForLoadState('networkidle');
expect(analyticsRequests.length).toBe(0);
});
Frequently Asked Questions
Does CCPA require a consent banner before scripts load?
No. CCPA/CPRA operates on an opt-out default: scripts may execute immediately unless the user has sent Sec-GPC: 1 or explicitly opted out via your CMP. A “Do Not Sell or Share My Personal Information” link is required, but not as a pre-condition for script execution.
How do I handle users who move between regions mid-session?
The routing policy resolves at request time from server-side geo headers. A VPN switch mid-session will not re-trigger the edge middleware for already-loaded pages. Guard against this by re-evaluating the policy on each navigation or route change in single-page apps, and by subscribing to CMP consent-change events to retroactively unload scripts if needed. The consent revocation without page reload page covers the unload mechanics.
Can I use a single CDN Vary header to cache regionally?
Yes, but only if your CDN supports header-based cache partitioning. Add Vary: CF-IPCountry, CF-IPRegion on the response. Without this, users in opt-in regions may receive a cached response built for opt-out regions, bypassing enforcement entirely. Monitor cache-hit ratio per region after deploying — EU should show a lower hit rate than US, which is expected and acceptable.
What replaces the IAB US Privacy String now that it is deprecated?
The IAB Tech Lab deprecated the US Privacy String in January 2024. The replacement is the Global Privacy Platform (GPP), specifically the US National section exposed via __gpp(). Legacy vendors still polling __uspapi can receive a shim string for backward compatibility, but all new integrations should target GPP.