Skip to content

Consent Mode does nothing if your tag loads first

4 min readProTechBridge

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.

  • Analytics
  • Privacy
  • Web development

Need a hand with this in practice?

Reading about it is one thing. If you want someone to actually do it, that is what we are for.