Google Consent Mode is a queue. You push a default consent state into
dataLayer, the tag reads it, and behaviour follows. The whole mechanism rests
on one assumption: the defaults are registered before the tag acts on them.
Miss that ordering and everything still looks right. The tag loads. The banner appears. The dashboard fills with data. Cookies are set before the visitor has chosen, and nothing anywhere reports a problem.
We shipped exactly that, and caught it only because we checked character positions in the built HTML.
What we wrote
The defaults went in the document head, before anything else, with the loader placed later in the body:
<head>
<script dangerouslySetInnerHTML={{ __html: `
gtag('consent','default',{ analytics_storage:'denied', ... });
`}} />
</head>
<body>
...
<script async src="https://www.googletagmanager.com/gtag/js?id=..." />
</body>
Read as source, that is correct. Defaults first, loader second.
What actually shipped
The framework hoists <script src> elements into the head during the build.
Searching the generated HTML for each:
gtag.js script char 1226
consent defaults char 3386
The loader had been lifted two thousand characters above the defaults it
was supposed to obey. The async attribute means the browser fetches it
immediately and executes whenever it is ready, which may well be before an
inline script further down the document has run.
That is a race, and races are the worst class of privacy bug, because they resolve differently depending on network speed. A visitor on fast fibre gets tracked without consent. A visitor on a slow connection does not. The same code is compliant or not depending on someone's broadband.
The fix is to remove the race, not to win it
The temptation is to shuffle the tags and re-check. That produces a build that happens to be ordered correctly today and breaks silently when someone moves a component or the framework changes its hoisting rules.
The durable fix is to stop having two scripts. One inline script registers the defaults and then injects the loader itself:
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('consent','default',{ analytics_storage:'denied', /* … */ });
gtag('config', MEASUREMENT_ID);
var s = document.createElement('script');
s.async = true;
s.src = 'https://www.googletagmanager.com/gtag/js?id=' + MEASUREMENT_ID;
document.head.appendChild(s);
There is no <script src> element for the build step to hoist, because there
is no element at all until the defaults have already run. Ordering stops being
a property of where tags sit in a file and becomes a property of execution.
Add the check to your build
The failure is invisible in the browser, so assert it where it can be seen:
grep -c 'src="https://www.googletagmanager' out/index.html # must be 0
If that returns anything other than zero, a hoistable tag has crept back in. One line, and it catches the regression that no amount of manual testing will.
Regional defaults are worth the extra work
While you are in there: Consent Mode accepts a region parameter, so defaults
can differ by jurisdiction.
gtag('consent','default',{ analytics_storage:'denied', region:['DE','FR','GB', /* … */] });
gtag('consent','default',{ analytics_storage:'granted' });
Deny where consent is required, grant elsewhere. This matters more than it looks: the regional default uses Google's own server-side geolocation, which is independent of anything the browser reports.
That gives you a safety property worth understanding. If you also decide whether to show the banner using a client-side signal (time zone, say) then a visitor that signal misclassifies is still protected by the server-side default. They simply are not offered a choice. The heuristic decides whether to ask, never whether to track. Those must stay separate concerns.
Verify in the browser, not in the HTML
A <script> tag in the markup proves the tag was requested, not that it ran,
and not that consent was applied. The check that actually settles it takes ten
seconds in the console:
document.cookie.split(';').map(c => c.trim().split('=')[0])
Google Analytics writes _ga_<MEASUREMENT_ID> only once the tag has
initialised a session. If that cookie exists before you have chosen anything,
your consent defaults are not doing their job: whatever the source says.
Before a choice, on a visitor in a consent-required region, that list should
contain no _ga entries at all.
What to take from this
Correct source is not correct output. Between what you write and what a browser executes sits a build step that reorders things for performance, and it has no idea that one of your scripts governs another.
Anywhere ordering carries a compliance meaning rather than just a performance one, assert the ordering in the artefact you actually ship, and check the runtime, not the markup.