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.
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.
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:
DOMContentLoadedfires → GTM container bootstraps →gtag('consent', 'default', {...})locks the state todenied- CMP config fetch returns → CMP renders modal → user accepts
- CMP calls
gtag('consent', 'update', { analytics_storage: 'granted' }) - GTM Consent Mode v2 honours the update — but only if the
wait_for_updatewindow 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.
Resolution: Promise-Based Consent Gate
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
tcloadedoruseractioncomplete— the TCF events that signal final consent state, not intermediatecmpuishownevents. - Bounded timeout. If the CMP CDN is down or slow, the gate resolves to
deniedafter 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
setTimeoutas 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 (ReactStrictMode, 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 theupdatecall.
Related