Analytics and marketing scripts execute during framework hydration before the Consent Management Platform (CMP) resolves — firing network requests, creating GDPR exposure, and inflating Largest Contentful Paint before any user has clicked “Accept”.
Triage Workflow
Work through these steps in order to confirm the race condition before writing any code.
-
Open DevTools → Network panel. Filter by
Fetch/XHRandJS. Hard-refresh with cache disabled (Cmd+Shift+R/Ctrl+Shift+R). -
Check request timing. Look for requests to analytics endpoints (e.g.
google-analytics.com,analytics.tiktok.com,bat.bing.com) that appear before the consent modal is visible on screen. Requests firing within the first 200 ms of navigation nearly always indicate pre-consent execution. -
Inspect the TCF API state at load. Paste this in the Console immediately after page load:
window.__tcfapi?.('ping', 2, console.log)If
cmpLoadedisfalsebutwindow.gtagis already defined as a function, the race condition is confirmed. -
Run the reproduction checklist:
-
Measure the timing gap. Note the timestamp of the first analytics request vs. the timestamp of the
tcloadedTCF event. Any positive delta — scripts firing beforetcloaded— is a violation.
Root Cause: The Hydration-vs-CMP Race Condition
The browser parses and executes scripts in document order. When a GTM container or analytics snippet lives in <head>, the browser fetches and runs it synchronously (or asynchronously via defer/async) during the initial parse — long before the CMP’s own async network fetch completes and the consent modal renders.
In frameworks such as Next.js, React hydration fires client-side script lifecycle hooks (next/script strategy="afterInteractive" still runs on hydration, not on consent). The result is the same: the analytics SDK initialises, reads cookies, and fires identification beacons before any legal basis for processing exists.
This is the architectural gap that GDPR-compliant consent gating addresses at the enforcement layer — every downstream script injection must be blocked until the consent state is known and affirmative.
The Correct GTM Consent Mode v2 Initialisation Order
GTM Consent Mode v2 requires the default consent state to be set before the GTM container loads. Without the default call, GTM assumes consent is granted.
<!-- Step 1: define gtag and set all categories to denied BEFORE the GTM snippet -->
<script>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
// Lock all consent categories to denied before GTM initialises
gtag('consent', 'default', {
ad_storage: 'denied',
analytics_storage: 'denied',
functionality_storage: 'denied',
personalization_storage: 'denied',
security_storage: 'granted', // security cookies are usually exempt
wait_for_update: 500 // ms to wait for CMP to call 'update'
});
</script>
<!-- Step 2: GTM container — it now starts in a fully denied state -->
<script async src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXX"></script>
Consent Mode v2 suppresses data collection and anonymises pings but does not prevent the GTM container JS from executing. To achieve zero network activity before consent, gate the container load itself — see the resolution below.
Resolution Path
Replace all static script tags for non-essential vendors with a single consent-gated dynamic loader. The pattern has two parts: a waitForConsent() Promise and a loadScriptAfterConsent() injector.
The Complete Consent-Gated Loader
// consent-loader.ts
// Production-safe: handles TCF v2.2 CMPs and non-TCF CMPs via custom events.
export async function loadScriptAfterConsent(config: {
src: string;
id: string;
async?: boolean;
}): Promise<void> {
// Block until the user has made an affirmative choice (or CMP times out)
await waitForConsent();
// Guard against duplicate injection (e.g. React StrictMode double-invoke)
if (document.getElementById(config.id)) return;
const script = document.createElement('script');
script.src = config.src;
script.id = config.id;
script.async = config.async ?? true;
document.head.appendChild(script);
return new Promise((resolve, reject) => {
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Script failed to load: ${config.src}`));
});
}
function waitForConsent(): Promise<void> {
return new Promise((resolve, reject) => {
const TIMEOUT_MS = 6000; // Reject if CMP takes longer than 6 s
const deadline = setTimeout(
() => reject(new Error('CMP consent timeout — script blocked')),
TIMEOUT_MS
);
function listenViaTCF() {
// TCF v2.2: addEventListener fires on tcloaded and useractioncomplete
window.__tcfapi('addEventListener', 2, (tcData, success) => {
if (!success) return;
const resolved =
tcData.eventStatus === 'tcloaded' ||
tcData.eventStatus === 'useractioncomplete';
if (resolved) {
// Remove listener to avoid memory leak
window.__tcfapi('removeEventListener', 2, () => {}, tcData.listenerId);
clearTimeout(deadline);
// Purpose 1 = "Store and/or access information on a device" — required for analytics cookies
if (tcData.purpose?.consents?.[1]) {
resolve();
} else {
reject(new Error('Analytics consent denied by user'));
}
}
});
}
if (typeof window.__tcfapi === 'function') {
// TCF CMP is already present
listenViaTCF();
} else {
// Non-TCF CMP: wait for a custom event (e.g. OneTrust, Cookiebot)
window.addEventListener('cmp:consentResolved', (e: Event) => {
const detail = (e as CustomEvent<{ analyticsConsented: boolean }>).detail;
clearTimeout(deadline);
if (detail.analyticsConsented) {
resolve();
} else {
reject(new Error('Analytics consent denied by user'));
}
}, { once: true });
// Also poll for late-loading TCF CMPs (some inject __tcfapi after DOMContentLoaded)
const pollInterval = setInterval(() => {
if (typeof window.__tcfapi === 'function') {
clearInterval(pollInterval);
listenViaTCF();
}
}, 100);
// Stop polling when the deadline fires
setTimeout(() => clearInterval(pollInterval), TIMEOUT_MS);
}
});
}
React Integration (Prevents SSR Hydration Mismatch)
Wrap the loader in useEffect so it runs strictly after mount on the client — never during SSR or hydration:
// ConsentGatedAnalytics.tsx
import { useEffect } from 'react';
import { loadScriptAfterConsent } from './consent-loader';
export function ConsentGatedAnalytics() {
useEffect(() => {
loadScriptAfterConsent({
src: 'https://www.google-analytics.com/analytics.js',
id: 'ga-sdk',
}).catch((err: Error) => {
// Consent denied or CMP timeout — expected, not an application error.
// Log to your RUM tool if you want to track denial rates.
if (process.env.NODE_ENV !== 'production') {
console.info('[analytics] blocked:', err.message);
}
});
}, []); // Empty deps: run once after first mount
return null; // No DOM output
}
Place <ConsentGatedAnalytics /> inside your root layout, after any CMP widget component, to ensure the CMP script itself loads first.
Consent-Gating the GTM Container Load
If you need zero GTM network activity before consent (not just data suppression), gate the container load itself:
// Load GTM only after affirmative consent
loadScriptAfterConsent({
src: 'https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXX',
id: 'gtm-container',
}).then(() => {
// Fire the standard GTM page view once the container is ready
window.dataLayer?.push({ event: 'gtm.js', 'gtm.start': new Date().getTime() });
}).catch(() => {
// Consent not given — GTM stays unloaded
});
The network waterfall impact of deferred scripts is significant: removing GTM from the initial critical path typically cuts 80–150 KB from the pre-consent JS payload and eliminates 1–3 render-blocking round trips.
Script Execution Sequence
The diagram below shows the timing relationship between the CMP initialisation, the consent Promise, and the deferred analytics script injection:
Verification
Open the Network panel, clear storage, and reload. Before clicking “Accept” on the consent modal, the Network panel must show zero requests to any analytics domain. After clicking “Accept”, analytics requests should appear within 500 ms.
Run this Playwright test in CI to lock the behaviour:
// tests/consent-gating.spec.ts
import { test, expect } from '@playwright/test';
test('analytics scripts are absent before consent', async ({ page }) => {
const analyticsRequests: string[] = [];
// Intercept all network requests and record analytics ones
page.on('request', req => {
const url = req.url();
if (/google-analytics|gtag|analytics/.test(url)) {
analyticsRequests.push(url);
}
});
await page.goto('https://your-site.com');
await page.waitForLoadState('networkidle');
// No analytics requests should have fired before consent interaction
expect(analyticsRequests).toHaveLength(0);
// Simulate accepting consent
await page.click('[data-testid="accept-all-consent"]');
// Wait for analytics to initialise post-consent
await page.waitForFunction(() => typeof window.gtag === 'function', { timeout: 3000 });
// At least one analytics request should now exist
expect(analyticsRequests.length).toBeGreaterThan(0);
});
A passing run confirms both the compliance gate (no pre-consent requests) and the functionality gate (analytics do load post-consent). For the performance side, check that Core Web Vitals improve in Chrome DevTools — LCP should improve by 150–300 ms on consent-denied sessions once analytics fetches are removed from the critical path.
Common Pitfalls
-
Polling instead of event listeners.
setIntervalchecks burn CPU on the main thread and delay resolution. Usewindow.__tcfapi('addEventListener', ...)for instant notification on state change. Only fall back to polling for the rare case where__tcfapiitself has not yet been defined. -
Gating the script but not the dataLayer pushes. If
window.dataLayer.push({event: 'page_view'})runs before the GTM container loads, GTM will replay those events on initialisation, sending pre-consent data. WrapdataLayer.pushcalls in the same consent check. -
Treating SSR and client hydration identically. A
useEffect-less implementation may run on the server (wherewindowis undefined) or fire during React hydration before the client is fully mounted. Always gate dynamic loaders insideuseEffect(() => {...}, [])to ensure client-only execution. See the debugging guide for CMP integration failures with analytics tags for server-side rendering edge cases.
Related
- Architecting GDPR-Compliant Consent Gating — the enforcement model this pattern implements
- Debugging CMP Integration Failures with Analytics Tags — diagnosing tag-firing failures after gating is applied
- Designing Graceful Fallback Chains for Blocked Scripts — what to serve when consent is denied
- Syncing Consent States Across Multiple Vendors — propagating the resolved consent state to every vendor SDK
- Optimising the Network Waterfall for External Assets — measuring the performance gains from deferred third-party loads