Generate Report →

Fixing GA4 Event Timing & Consent Mode v2 on Static Websites

Missing GA4 conversions on a static site usually means one thing: events fire before consent resolves or after navigation starts. Both have exact fixes.

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.

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

SymptomLikely causeCheck
Pageviews normal, all custom events lowEvent handler race on unloadNetwork → collect while clicking
Everything low, including pageviewsScript blocked until consentLoad page, check for any collect hit
EEA traffic missing, other regions fineConsent Mode v2 params absentLook for gcs= and gcd= in hit URL
Returning visitors counted as newStored consent replayed as update, not defaultCheck ordering in head
Real-time works, reports empty next dayEvent name invalid or reserved prefixGA4 DebugView error column
Conversions counted twiceHandler bound on both click and submitCount 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 named ga_form_submit never appears.
  • Parameter names: 40 characters; values: 100 characters. Longer values are truncated, not rejected — so a page_path on 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.

Run this article on your site

Audit my site's GA4 and consent implementation. Verify that a gtag('consent','default',...) call with analytics_storage, ad_storage, ad_user_data and ad_personalization all set to 'denied' plus wait_for_update executes synchronously in the head BEFORE gtag.js loads, that the cookie banner calls gtag('consent','update',...) rather than injecting the analytics script, and that outbound link and form submit handlers use sendBeacon or an event_callback instead of a setTimeout redirect. Then list every custom event whose name exceeds 40 characters or uses reserved GA4 prefixes.

Paste into Claude Code, ChatGPT, Cursor or Gemini. It executes the steps above against your own site.

Frequently Asked Questions

Why do my GA4 outbound link clicks show far fewer events than actual clicks?

The browser tears down the page on navigation before the measurement request completes. Standard XHR requests are cancelled mid-flight. The fix is transport that survives unload — GA4 uses navigator.sendBeacon by default, but if you added a custom handler or a preventDefault-then-redirect pattern with a setTimeout, you reintroduced the race. Use an event callback or let sendBeacon do its job.

What exactly does Consent Mode v2 add over the original Consent Mode?

Two new parameters: ad_user_data and ad_personalization, alongside the existing analytics_storage and ad_storage. Since March 2024 Google requires all four to be signalled for advertisers serving EEA traffic, and without them remarketing audiences and conversion modelling stop populating. Analytics reporting still works in modelled form, but audience features degrade.

Do I lose all data from users who decline cookies?

No, if consent mode is implemented correctly. With analytics_storage denied, gtag still sends cookieless pings containing no identifiers, and Google uses those to model the missing conversions. If you instead block the gtag script entirely until consent, no pings are sent, no modelling occurs, and that traffic is genuinely invisible.

How do I verify consent mode is actually working?

Open DevTools Network, filter for 'collect', and load the page before interacting with the banner. You should see a request with gcs=G100 (denied). After accepting, subsequent hits should show gcs=G111. If you see no request at all before consent, your default state was never set and modelling will not run.

Should the consent default command go in Google Tag Manager or directly in the page head?

Directly in the page head, before the gtag.js script loads and before any tag manager container. The default state must be set synchronously before any measurement call executes; routing it through a container introduces a race where the first pageview can fire under an undefined consent state.

Continue the track — Data-Driven Content Strategy