Google Consent Mode v2 Took Me a Week to Get Right — Here's What I Learned
Google Consent Mode v2 became mandatory in March 2024. I implemented it in March 2026 — two years late — and spent a week debugging silent failures that Google’s documentation doesn’t mention.
This is what I wish I had known before starting.
What Consent Mode v2 actually does
Before v2, you had two options with Google tags: block them entirely until consent, or let them fire and hope your consent banner covers you legally. Both options are bad — blocking means you lose analytics data, not blocking means you risk fines.
Consent Mode v2 gives you a third option: tell Google the user hasn’t consented, and let Google model the missing data. Google runs conversion modeling against aggregated patterns from consented users and fills in the gaps for users who said no.
The technical mechanism: Two consent states — default (before banner interaction, everything denied) and update (after user acts, reflects their choices). Four signals govern what Google can and can’t do.
The four signals most developers miss
ad_storage → Can Google set advertising cookies?
analytics_storage → Can Google set analytics cookies?
ad_user_data → Can Google send user data to Ads? (NEW in v2)
ad_personalization → Can Google personalize ads? (NEW in v2)
The last two are new in v2 and are the most common implementation error. If you have v1 code, you’re missing ad_user_data and ad_personalization. Google treats missing signals as “denied” — silently. Your ad measurement is degraded and you won’t know it.
Implementation: the execution order that breaks everything
The entire implementation hinges on script execution order. Consent defaults must run before any Google tag. Here’s the pattern for three frameworks.
Next.js (App Router)
// app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<Script id="consent-defaults" strategy="beforeInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
ad_storage: 'denied',
analytics_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
wait_for_update: 500,
region: ['EEA', 'CH', 'UK']
});
`}
</Script>
<Script
id="cookiebot"
src="https://consent.cookiebot.com/uc.js"
data-cbid="YOUR_ID"
strategy="afterInteractive"
/>
</head>
<body>{children}</body>
</html>
);
}
The thing that cost me 3 days: strategy="beforeInteractive" is not optional. If you use afterInteractive (Next.js default), hydration can reorder scripts and your Google tags load before consent defaults. You’ll see GA4 data for every visitor regardless of consent, and you won’t notice unless you check manually.
Astro
---
// src/layouts/BaseLayout.astro
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script is:inline>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
ad_storage: 'denied',
analytics_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
wait_for_update: 500,
region: ['EEA', 'CH', 'UK']
});
</script>
<script is:inline src="https://consent.cookiebot.com/uc.js" data-cbid="YOUR_ID"></script>
</head>
<body><slot /></body>
</html>
Why is:inline: Astro bundles and hoists scripts by default. is:inline keeps them in source position. Without it, Astro may reorder your consent defaults below the Cookiebot loader, and the defaults run after the CMP initializes — which is backwards and silently broken.
Vanilla JS
<!doctype html>
<html>
<head>
<!-- 1. Consent defaults — ABSOLUTE FIRST -->
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
ad_storage: 'denied',
analytics_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
wait_for_update: 500,
region: ['EEA', 'CH', 'UK']
});
</script>
<!-- 2. CMP — SECOND -->
<script src="https://consent.cookiebot.com/uc.js" data-cbid="YOUR_ID"></script>
<!-- 3. GTM — THIRD -->
<script>
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});...})(window,document,'script','dataLayer','GTM-XXXXX');
</script>
</head>
<body>...</body>
</html>
The update callback: what fires after banner interaction
The default state tells Google “don’t collect.” The update call tells Google “now the user chose something”:
// When user clicks Accept All
gtag('consent', 'update', {
ad_storage: 'granted',
analytics_storage: 'granted',
ad_user_data: 'granted',
ad_personalization: 'granted',
});
// When user clicks Reject All
gtag('consent', 'update', {
ad_storage: 'denied',
analytics_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
});
How this integrates with your CMP: Some CMPs call this for you. Cookiebot and Usercentrics handle it automatically — you don’t write the update call. Klaro does not → — you need to hook into its callback and call gtag('consent', 'update') yourself. Check your CMP’s documentation. Getting this wrong is the second most common failure I see.
The verification method Google doesn’t document well
1. Check the dataLayer (5 seconds)
DevTools → Console, type:
dataLayer.filter(e => e[0] === 'consent')
Expected: two consent events — ['consent', 'default', {...}] and ['consent', 'update', {...}]. If you only see default, your CMP’s callback isn’t firing the update call.
2. Tag Assistant (the only reliable way)
Install the Google Tag Assistant Companion Chrome extension. It gives you a timeline of consent state changes. Watch the moment you click “Accept All” — you should see ad_storage flip from denied to granted within 500ms.
This caught an error I would never have found otherwise: One of my storefronts had a stale service worker serving a cached version of the page without the consent defaults. Tag Assistant showed the consent state as undefined — not denied, not granted, just missing. The page was loading from cache and skipping the consent script entirely. Clearing the service worker fixed it.
3. GA4 Real-Time (confirms blocking works)
Go to GA4 → Reports → Realtime. Open your site in incognito:
- Before banner interaction: no users visible
- After Accept All: users appear
- After Reject All: users do NOT appear
If users appear before you touch the banner, your defaults aren’t running first.
Five silent failures I encountered
1. Stale service worker. Cached page without consent script. Tag Assistant showed consent state as undefined. Fix: version your service worker and invalidate cache on deploy.
2. Duplicate consent calls. My CMP was calling gtag('consent', 'default') automatically AND I had manual code. Two default calls created a race condition — sometimes the manual one ran second and overrode the CMP’s values with denied after consent was given. Fix: pick one source of truth.
3. Missing region parameter. I deployed consent defaults without region: ['EEA', 'CH', 'UK']. Consent rules applied globally, and my US analytics disappeared. Took me 4 hours to figure out why GA4 was showing zero traffic from a US-based ad campaign.
4. wait_for_update too short. I set it to 100ms. On slow connections, the CMP didn’t respond in time and Google treated the missing update as “consent denied indefinitely.” Fix: 500ms minimum.
5. Framework hydration reordering. Next.js afterInteractive strategy moved the consent script below the CMP loader during hydration. The consent default ran after GA4 initialized. Fix: beforeInteractive.
Do you actually need this?
You need Consent Mode v2 if: you use any Google service (GA4, Ads, GTM) and serve visitors in the EEA, UK, or Switzerland.
You don’t need it if: you use only self-hosted analytics (Plausible, Matomo, Umami) and run no Google Ads. But you still need a consent mechanism for non-Google trackers →
I switched one of my projects from GA4 to self-hosted Umami specifically to avoid Consent Mode v2 complexity. For a simple marketing site, removing Google entirely was faster than implementing v2 correctly.
If you’re processing user data with AI features, the EU AI Act adds consent requirements beyond Consent Mode v2 →
Tested on Next.js 14, Astro 6, and vanilla JS. Service worker bug discovered in production, March 2026. Scanner used: my open-source GDPR checker.