The symptom is always the same. Google Analytics 4 shows traffic, the real-time report looks alive, but the conversion counts are obviously wrong — a form that gets twenty submissions a week records four, outbound clicks to the booking system barely register, and the numbers in GA4 do not resemble what the business knows happened.
On a static site this is almost never a tagging-plan problem. It is a timing problem. Events are firing before consent state exists, or after the browser has already begun tearing down the page, and in both cases the measurement request never completes.
This walks the two failure modes separately — consent ordering and unload races — with the exact code that fixes each and the DevTools checks that prove it worked.
Failure mode one: events fire before consent state exists
Google’s consent mode works by having gtag know the consent state before it sends anything. If no default is declared, the very first pageview executes under an undefined state, and depending on load order it either sends a fully-identified hit that should not have been sent, or nothing at all.
The correct ordering is strict and non-negotiable: default consent command, then gtag.js, then config, then later an update when the user chooses.
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
'ad_storage': 'denied',
'ad_user_data': 'denied',
'ad_personalization': 'denied',
'analytics_storage': 'denied',
'functionality_storage': 'granted',
'security_storage': 'granted',
'wait_for_update': 500
});
</script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX', { 'anonymize_ip': true });
</script>Three things are doing real work here. The default block is a plain synchronous <script> with no async or defer, so it runs before anything else touches dataLayer. wait_for_update: 500 gives an asynchronously-loaded banner half a second to report a stored choice before gtag proceeds — without it, a returning visitor who already consented gets counted as denied on every first pageview. And functionality_storage / security_storage stay granted because they are not analytics or advertising storage.
Then the banner updates state rather than injecting anything:
function acceptAll() {
gtag('consent', 'update', {
'ad_storage': 'granted',
'ad_user_data': 'granted',
'ad_personalization': 'granted',
'analytics_storage': 'granted'
});
localStorage.setItem('consent_choice', 'granted');
}The mistake that costs the most data is the intuitive one: keeping the GA4 script out of the page entirely until the user clicks accept. That feels more privacy-respecting and is technically defensible, but it means no cookieless pings are sent, so Google has nothing to model from — and every visitor who scrolls past the banner without answering it becomes completely invisible rather than partially modelled. Consent mode’s entire value is that denial still produces an anonymous, identifier-free signal.
On a returning visit, replay the stored choice as a default, not as an update, so it applies before the first hit:
var stored = localStorage.getItem('consent_choice');
if (stored === 'granted') {
gtag('consent', 'default', {
'ad_storage': 'granted', 'ad_user_data': 'granted',
'ad_personalization': 'granted', 'analytics_storage': 'granted'
});
}Failure mode two: the page dies before the request lands
The second class of loss has nothing to do with consent. When a user clicks an outbound link, a tel: link, or submits a form, the browser begins unloading the document. Any in-flight XHR is cancelled.
The pattern that causes this is everywhere in tutorial code:
// Broken — the redirect races the measurement request
link.addEventListener('click', function(e) {
e.preventDefault();
gtag('event', 'outbound_click', { link_url: this.href });
setTimeout(() => { window.location = this.href; }, 100);
});The 100ms delay is a guess. On a slow connection the request has not left; on a fast one you added a perceptible stall for nothing. Either way you have taken over navigation and made it worse.
GA4 already uses navigator.sendBeacon, which the browser is obligated to complete even after the document unloads. The correct handler does not prevent default at all:
document.querySelectorAll('a[href^="http"]:not([href*="marketlens.work"])')
.forEach(function(link) {
link.addEventListener('click', function() {
gtag('event', 'outbound_click', {
link_url: this.href,
link_domain: this.hostname
});
});
});For cases where you genuinely must wait — a form that posts to a third-party endpoint, for example — use event_callback with a event_timeout guard rather than a fixed delay:
gtag('event', 'generate_lead', {
'event_timeout': 2000,
'event_callback': function() { form.submit(); }
});event_callback fires as soon as the hit is acknowledged, so fast connections proceed immediately and slow ones still get their guaranteed ceiling.
Phone links deserve a specific mention because they are the highest-value event for most local businesses and the most frequently lost. A tel: click hands control to the OS dialer, which on iOS can suspend the page instantly. sendBeacon survives that; a setTimeout does not. Register the surviving events as key events in GA4 Admin afterwards, or they will be recorded accurately and still never appear in a conversion report.
Diagnosing which failure you have
| Symptom | Likely cause | Check |
|---|---|---|
| Pageviews normal, all custom events low | Event handler race on unload | Network → collect while clicking |
| Everything low, including pageviews | Script blocked until consent | Load page, check for any collect hit |
| EEA traffic missing, other regions fine | Consent Mode v2 params absent | Look for gcs= and gcd= in hit URL |
| Returning visitors counted as new | Stored consent replayed as update, not default | Check ordering in head |
| Real-time works, reports empty next day | Event name invalid or reserved prefix | GA4 DebugView error column |
| Conversions counted twice | Handler bound on both click and submit | Count collect hits per interaction |
The single most useful check is the Network panel filtered to collect. Every GA4 hit is one request, and the query string tells you everything: en= is the event name, gcs= is the consent signal (G100 = analytics denied, G111 = granted), and gcd= encodes the v2 defaults. If you load a page in a fresh incognito window and see zero collect requests before touching the banner, consent mode is not implemented — it is just blocked analytics.
GA4’s DebugView is the second tool, but it only shows traffic from sessions with debug mode enabled. Add ?_dbg=1 via the GA Debugger extension rather than shipping debug_mode: true to production, which floods DebugView with real user traffic and makes it useless.
Event naming rules that silently drop data
Some events vanish for reasons unrelated to timing. GA4 enforces limits that fail quietly rather than erroring:
- Event names: 40 characters max, letters/numbers/underscores only, must start with a letter. A name with a hyphen or space is dropped.
- Reserved prefixes:
google_,ga_,firebase_. An event namedga_form_submitnever appears. - Parameter names: 40 characters; values: 100 characters. Longer values are truncated, not rejected — so a
page_pathon a long URL silently loses its tail. - 25 parameters per event, 50 custom dimensions per property.
Register custom parameters as custom dimensions in GA4 Admin before you need them. Data collected before registration is not retroactively available in reports, which is a painful discovery three weeks into a campaign.
Why this matters beyond compliance
Broken measurement is not a reporting inconvenience — it changes decisions. If phone clicks are undercounted, the pages actually driving revenue look mediocre, and the content investment goes somewhere less productive. In the dental clinic teardown, direct contact actions went from 503 to 1,135 year over year against organic sessions of 13,817 to 29,384 — the fact that conversions outpaced sessions at all was only knowable because phone taps, landline taps, and email clicks were each firing as their own reliable event. Had any one of them been racing an unload, the conclusion would have been the opposite one.
There is a second reason to care right now. Referral traffic from ChatGPT, Perplexity, and Gemini needs to be segmented rather than dumped into direct, and you cannot segment what was never collected — which puts the consent race directly between you and any read on whether being cited in ChatGPT Search is worth pursuing.
Fix it in one sitting
Open your site in a fresh incognito window with DevTools Network filtered to collect. Before touching the cookie banner, confirm one request exists and its gcs parameter reads G100. Accept, confirm the next hit reads G111. Then click an outbound link and a phone link and confirm a hit fires for each.
Three checks, five minutes, and they identify which of the two failure modes you have. Fix the ordering in <head> first — it is one block of code and it recovers the modelled data for every visitor who ignores your banner. If you want the literal script tags, consent parameters, and event names on your live pages reported back exactly as they appear in the DOM, that inventory is part of the Standard MarketLens audit.
MarketLens