Four days after I shipped what I thought was the fix, GA4 was still reporting zero. Not low. Zero. The Realtime view sat empty while the site took real traffic, and the 30-day report showed a signature I will not forget: two users, two events, zero sessions. Those two users were me, clicking around on launch day.

The site was live. The tag was installed. The consent banner worked. And nothing reached Google, because the fix I had shipped four days earlier was gated behind the exact condition it existed to remove.

The bug

The site uses Consent Mode v2. The idea is that analytics loads for everyone in a denied-by-default state, sends cookieless modelling pings so you get aggregate data without consent, and upgrades to full tracking only when the visitor accepts. The whole point is that something fires before anyone clicks a button.

My implementation put the setup in one function:

function loadGtag() {
  // inject gtag.js
  // consent('default', { analytics_storage: 'granted', ... })
}

Then it gated that function:

if (localStorage.getItem('CONSENT_ACCEPTED')) {
  loadGtag()
}

Read those two blocks together and the failure is total. loadGtag() held both the script injection and the default consent signal, and it only ran for visitors who had already accepted. A first-time visitor loaded no gtag.js, fired no /collect beacon, and sent Google nothing. The explicit default grant, the one piece of Consent Mode v2 that is supposed to run unconditionally, only ran for people who had already opted in. It was consent mode with the consent step wired backwards.

Worse, it was invisible. The banner worked. My own accept clicks loaded the tag and sent a beacon, so every time I tested by accepting, I saw traffic. The two-users signature was the tell: the only data GA4 ever got came from the handful of times I clicked Accept myself.

The fix

Consent Mode v2 has a textbook shape, and the rewrite followed it. A raw inline script at the top of the document, not a framework wrapper, so DOM ordering is parser-deterministic:

gtag('consent', 'default', {
  analytics_storage: 'denied',
  ad_storage: 'denied',
  ad_user_data: 'denied',
  ad_personalization: 'denied',
  wait_for_update: 500,
})
gtag('config', 'G-PV5QZBWTTV', { anonymize_ip: true })

gtag.js then loads for every visitor and inherits the already-denied state. Nothing is gated. Every visit sends a cookieless ping with gcs=G100, which is the signal for "consent denied, here is aggregate modelling data anyway." The banner no longer loads anything. It only sends a consent update: accept flips analytics_storage to granted, decline leaves it denied.

The compliance posture came out stronger, not weaker. No analytics cookie is written until acceptance, the default pings carry no personal data, and the whole thing sits inside the denied-by-default model that UK and EEA guidance asks for. Denying by default and measuring anyway is more correct than what I had, which measured nobody.

What would have caught it on day one

The original two-layer fix was verified by accepting the banner and watching the beacon fire. That test passes on a broken build, because accepting is the one path that worked. The check that mattered was the one I never ran: open a fresh incognito window, do not touch the banner, and confirm a /collect request goes out with gcs=G100 before any click.

That is now step zero whenever I touch consent code. When you ship a fix for a default that is supposed to fire for non-accepters, verify it fires for a non-accepter. Test the state you claim to have fixed, not the state that was already fine.

All writing