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.

  1. Open DevTools → Network panel. Filter by Fetch/XHR and JS. Hard-refresh with cache disabled (Cmd+Shift+R / Ctrl+Shift+R).

  2. 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.

  3. Inspect the TCF API state at load. Paste this in the Console immediately after page load:

    window.__tcfapi?.('ping', 2, console.log)

    If cmpLoaded is false but window.gtag is already defined as a function, the race condition is confirmed.

  4. Run the reproduction checklist:

  5. Measure the timing gap. Note the timestamp of the first analytics request vs. the timestamp of the tcloaded TCF event. Any positive delta — scripts firing before tcloaded — 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.

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.

// 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.

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:

Consent-gated script loading sequence Timeline showing the browser parsing the page, the CMP loading asynchronously, the user accepting consent, the waitForConsent Promise resolving, and only then the analytics script being injected and firing its network request. Browser CMP Analytics SDK parse + fetch CMP modal rendered user accepts tcloaded / useractioncomplete Promise resolves inject <script> tag → network request SDK initialises blocked until consent

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. setInterval checks burn CPU on the main thread and delay resolution. Use window.__tcfapi('addEventListener', ...) for instant notification on state change. Only fall back to polling for the rare case where __tcfapi itself 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. Wrap dataLayer.push calls in the same consent check.

  • Treating SSR and client hydration identically. A useEffect-less implementation may run on the server (where window is undefined) or fire during React hydration before the client is fully mounted. Always gate dynamic loaders inside useEffect(() => {...}, []) to ensure client-only execution. See the debugging guide for CMP integration failures with analytics tags for server-side rendering edge cases.


Related

Up: Architecting GDPR-Compliant Consent Gating