GA4 or GTM fires before the CMP consent modal resolves — or events disappear entirely after the user accepts — because analytics tag initialization and CMP resolution run on independent async timelines that share no synchronization primitive by default.

CMP vs Analytics Tag Race Condition A sequence diagram showing that GTM initializes synchronously on DOMContentLoaded while the CMP fetches its config asynchronously, creating a 200–800ms window where tags fire with stale or denied consent state. Browser / DOM GTM / Analytics CMP (async) DOMContentLoaded consent: default=denied fetch /cmp-config.json (async) collect? fires (gcs=G1--) 200–800 ms race window tags fire with stale state tcloaded / useractioncomplete consent: update=granted update ignored — pipeline already fired

Triage Workflow: Reproduce the Race Condition

Work through these steps in order. Each one either confirms or eliminates a specific failure mode before you move to the next.

// Intercept dataLayer to log consent event timing — paste in DevTools console
const _push = window.dataLayer.push.bind(window.dataLayer);
window.dataLayer.push = function (...args) {
  console.log(`[DL ${Date.now()}]`, JSON.stringify(args[0]));
  return _push(...args);
};

If the log shows gtm.js loading before the first consent: update event, the race is confirmed.

Before reading further, it is worth naming the four places the signal can be lost, because the symptom — a tag that never fires — is identical at all four and the fix is different at each.

Four places a consent signal goes missing A consent signal travels through four hand-offs. The platform must resolve, which fails if its bundle never loads. Your adapter must observe the resolution, which fails if it reads a snapshot instead of subscribing. The tag manager must receive the update, which fails if the trigger listens for the wrong event name. The tag itself must have its consent requirement satisfied, which fails if the tag is configured to require a category the visitor was never asked about. Each hand-off carries the observation that identifies it. 1 · platform resolves ping → cmpStatus 2 · adapter observes log inside the callback 3 · trigger fires preview mode event list 4 · tag permitted tag consent settings bundle blocked or 404 read a snapshot, not a subscription trigger listens for the wrong name requires an unasked category All four present as "the tag never fires". Work left to right and stop at the first hand-off that fails. Skipping to the tag configuration is the usual mistake — step 4 is the least likely of the four. Instrument all four permanently: four console lines turn a two-hour trace into a ten-second read.

Leave that instrumentation in behind a debug flag. Consent integrations break most often when someone else changes a platform setting — a renamed category, a new region rule — and the team that has to diagnose it is rarely the team that built it.

Root Cause: Two Independent Async Timelines

The GDPR-compliant consent gating architecture requires consent state to be resolved before any analytics SDK initializes. The failure occurs when that ordering is not enforced at the code level.

Analytics libraries (gtag.js, analytics.js) execute synchronously during DOMContentLoaded because they are injected via <script> tags in <head>. CMPs fetch their vendor list and configuration from a remote JSON file — an async network operation that resolves 200–800 ms later depending on CDN latency and CPU pressure. Without an explicit synchronization mechanism, the JS event loop processes both paths independently:

  1. DOMContentLoaded fires → GTM container bootstraps → gtag('consent', 'default', {...}) locks the state to denied
  2. CMP config fetch returns → CMP renders modal → user accepts
  3. CMP calls gtag('consent', 'update', { analytics_storage: 'granted' })
  4. GTM Consent Mode v2 honours the update — but only if the wait_for_update window has not already elapsed

If step 3 occurs after wait_for_update expires (default 500 ms, max 5000 ms), the tag manager abandons the queue and subsequent consent: update calls are silently ignored. This is the mechanism behind silent drops. Premature firing is the inverse: when gtag('consent', 'default') is called after the GTM container snippet rather than before it, the container bootstraps without any state and applies permissive defaults.

Both failure modes are symptoms of the same missing primitive: a consent-resolution Promise that the analytics initialization explicitly awaits. The delaying of third-party scripts until user consent describes this deferral pattern in depth; here the focus is the specific mechanics for GA4/GTM.

The fix has two mandatory parts that must both be in place:

Part 1 — Set consent defaults before the GTM snippet. This must run as the very first <script> in <head>, before the GTM container tag.

<!-- MUST be the first script in <head> — before GTM container snippet -->
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag() { dataLayer.push(arguments); }

  // Lock GTM into denied state before the container bootstraps.
  // wait_for_update gives the CMP up to 5 s to call gtag('consent','update').
  gtag('consent', 'default', {
    ad_storage:            'denied',
    analytics_storage:     'denied',
    ad_user_data:          'denied',
    ad_personalization:    'denied',
    wait_for_update:       5000
  });
</script>

Part 2 — Await TCF resolution before updating state and injecting analytics. Place this immediately after the GTM container snippet.

/**
 * Consent gate for GA4 + GTM Consent Mode v2 / IAB TCF 2.2.
 * Waits for the CMP to signal tcloaded or useractioncomplete,
 * then updates the consent state and conditionally injects analytics.
 */
(function () {
  'use strict';

  const MAX_WAIT = 5000; // match wait_for_update above

  function waitForConsent() {
    return new Promise((resolve) => {
      const deadline = Date.now() + MAX_WAIT;

      function poll() {
        if (typeof window.__tcfapi !== 'function') {
          // TCF API not yet injected — retry on next tick
          if (Date.now() < deadline) {
            setTimeout(poll, 50);
          } else {
            // CMP failed to load within budget — default to denied
            console.warn('[ConsentGate] CMP timeout. Defaulting to denied.');
            resolve('denied');
          }
          return;
        }

        window.__tcfapi('addEventListener', 2, function (tcData, ok) {
          if (!ok) { resolve('denied'); return; }

          const terminal = tcData.eventStatus === 'tcloaded' ||
                           tcData.eventStatus === 'useractioncomplete';
          if (!terminal) return; // wait for next event

          // Remove listener immediately to prevent duplicate updates
          window.__tcfapi('removeEventListener', 2, function () {}, tcData.listenerId);

          // TCF Purpose 1 = store/access information on a device (analytics)
          const granted = !!(tcData.purpose && tcData.purpose.consents[1]);
          resolve(granted ? 'granted' : 'denied');
        });
      }

      poll();
    });
  }

  waitForConsent().then(function (state) {
    // Update GTM consent state — GTM Consent Mode v2 will replay queued tags
    gtag('consent', 'update', {
      ad_storage:         state,
      analytics_storage:  state,
      ad_user_data:       state,
      ad_personalization: state
    });

    if (state === 'granted') {
      // Inject the GA4 script only after consent is confirmed.
      // Replace G-XXXXXXX with your Measurement ID.
      var s = document.createElement('script');
      s.src  = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX';
      s.async = true;
      document.head.appendChild(s);
    }
  });
}());

The three guarantees this pattern enforces:

  • Immediate safe default. gtag('consent', 'default') runs synchronously before any async operation, so GTM’s internal state machine never enters an undefined state.
  • Deterministic resolution. The gate blocks analytics injection until tcloaded or useractioncomplete — the TCF events that signal final consent state, not intermediate cmpuishown events.
  • Bounded timeout. If the CMP CDN is down or slow, the gate resolves to denied after 5 s rather than hanging indefinitely, preserving compliance over functionality.

For non-TCF CMPs (OneTrust, Cookiebot proprietary APIs), replace the __tcfapi listener with the vendor’s own event — e.g. OneTrust’s OptanonWrapper callback or Cookiebot’s CookiebotOnAccept event — while keeping the same Promise wrapper structure.

Verification: Single Concrete Check

After deploying the fix, open DevTools → Network, filter for collect, and reload with CPU 4× throttle active.

The collect request must not appear until after the CMP consent modal has been interacted with. Check the gcs query parameter on the first outgoing collect request: it must read G111 (all consent granted) rather than G1--. If no collect fires at all when consent is denied, that is the correct behaviour — not a bug.

For ongoing production monitoring, attach a PerformanceObserver to confirm analytics load order:

// Paste in DevTools console or deploy as a RUM snippet (non-blocking)
new PerformanceObserver(function (list) {
  list.getEntries().forEach(function (entry) {
    if (!entry.name.includes('collect') && !entry.name.includes('gtm.js')) return;
    var cmp = performance.getEntriesByType('resource')
                .find(function (r) { return r.name.includes('cmp') || r.name.includes('consent'); });
    var delta = entry.startTime - (cmp ? cmp.responseEnd : 0);
    if (delta < 0) {
      console.error('[Compliance] Analytics fired ' + Math.abs(delta).toFixed(0) + 'ms BEFORE CMP.');
    } else {
      console.log('[OK] Analytics deferred ' + delta.toFixed(0) + 'ms after CMP.');
    }
  });
}).observe({ entryTypes: ['resource'] });

A positive delta on every page load confirms the fix is working. A negative delta means the ordering guarantee has broken — re-check script placement in <head>.

Common Pitfalls

  • setTimeout as a consent delay. Hardcoding a fixed delay (e.g. setTimeout(loadGA, 2000)) introduces non-deterministic behaviour: fast devices fire analytics before slow CMPs resolve; slow devices miss the window entirely. Use TCF event callbacks instead.
  • Placing gtag('consent', 'default') after the GTM snippet. The entire point of the default call is to run before GTM bootstraps. If it runs after, GTM has already initialized with no consent state and applied its own permissive defaults — the premature-firing failure mode described above.
  • Calling gtag('consent', 'update') more than once per session. Framework re-renders (React StrictMode, Next.js RSC transitions) can trigger the CMP callback multiple times. Each duplicate update call resets GTM’s internal state and can cause double-counted events. Guard the update behind a session-scoped flag: if (sessionStorage.getItem('consent_sent')) return; before the update call.
A permanent debug surface beats a one-off trace Two approaches to diagnosing a consent integration. An ad hoc approach requires reproducing the failure, adding temporary logging, redeploying and removing the logging afterwards, and yields nothing the next time. A permanent debug surface behind a query flag exposes the platform status, the adapter state, the last event received and the resolved categories on every environment, and answers the same question in seconds without a deploy. ad hoc · every time reproduce · add logging · deploy read · remove logging · deploy again and the next incident starts from zero a debug surface · built once cmpStatus · adapterState lastEvent · resolvedCategories behind ?debugConsent=1, in every environment answers the same question without a deploy Consent breakages are usually caused by a console setting nobody in engineering changed. Give the people who change those settings a way to see the result themselves.

Frequently Asked Questions

Why does the tag fire in preview mode but not for real visitors?

Preview mode usually runs with a debug cookie already set, and on many platforms that cookie also short-circuits the consent banner — so the session you are debugging has a resolved state from the first millisecond while a real visitor does not. The race you are trying to observe cannot occur in the environment you are observing it in.

Reproduce with a genuinely clean profile: a new incognito window, consent cookies cleared, and network throttled so the platform’s bundle takes a realistic time to arrive. If the tag fires there too, the defect is region- or category-specific rather than a timing problem, and the grid of vendor-by-jurisdiction rules is the next thing to check.


Related

Up: Architecting GDPR-Compliant Consent Gating