Guides

Consent Mode v2 on a marketing site: the order that makes the notice real

The denied default has to run inline before gtag.js loads, or Google ignores it. What the four signals and wait_for_update do, why the loader is injected under React 19, why the collector's update needs a gtag the host defined, and how to check the order from outside.

Updated

The short version

  • Google reads a consent default only if it arrives before gtag.js loads, so the denied default is an inline script rendered ahead of the loader.
  • The default denies all four signals, and the collector only ever sends an update on top of it, never a default of its own.
  • The collector calls only a gtag the host page already defined, so the order is bootstrap, loader, collector — swap it and nothing reports an error.
  • You can check the order from outside: two inline scripts in the served HTML, and the collector's element only once the page is interactive.

Consent starts as no, and it has to be said before Google loads

Two scripts, one page, and the order they run in decides whether the notice means anything.

Google reads a consent default only if it arrives before gtag.js has loaded. A default that lands afterwards is ignored: the first page view has already gone out under whatever the library assumed. So the one line that matters is a consent default with every signal denied, in an inline synchronous script rendered ahead of the loader — not an external file, not a deferred script, not a component effect, none of which can be ordered ahead of gtag.js reliably.

On this site that script opens window.dataLayer, defines gtag, assigns it to window.gtag explicitly, and calls the default. The explicit assignment is not tidiness. The collector reads window.gtag, and a bare function declaration inside a bundled or minified scope would not necessarily land there — which would bring back the silent failure described further down.

This site has no cookie banner of its own and is not getting one. The consent surface is the notice drawn by Runner's own collector, which records the visitor's decision server-side as evidence. The host page's job is the denied default underneath it, and this guide is about getting that one job right.

Try it

Open the console on this page and type dataLayer[0]. That's the consent default: analytics_storage, ad_storage, ad_user_data and ad_personalization, all denied, with wait_for_update at 500. The js and config calls the loader pushed sit after it, and anything the collector tells Google lands further down as an update, never as a second default.

The notice itself, on the product page

The four signals, every one denied

The baseline declares four signals and denies all of them. They are the signals the collector's bridge updates from its analytics and marketing categories, so the default and the update are talking about the same things.

Nothing in this repository ever sets a signal to granted. There is deliberately no accept state in the baseline: the site owns no surface that could collect one, and a granted default written here would be consent no visitor gave.

The honesty guard asserts that three times over — against the exported constant, against the script string built from it, and against the whole rendered layout — because the constant and the script could disagree, and the script is what a visitor's browser runs.

What can turn a signal on is the collector. It passes on a choice the visitor makes, replays one they made before, or applies one the page sets through its API. And on a site not set to ask first, it switches on analytics and marketing, where the site declares them, for a visitor whose region doesn't require asking — unless that visitor already decided something, in either direction, or their browser sends a Global Privacy Control signal. This site is set to inform and declares analytics, so for that visitor the collector sends analytics_storage as granted, in an update on top of the denied default. For every other visitor, nothing is granted until they choose.

  • analytics_storage — updated from the collector's analytics category
  • ad_storage — updated from the marketing category
  • ad_user_data — updated from the marketing category
  • ad_personalization — updated from the marketing category

wait_for_update, and what the 500ms buys

wait_for_update: 500 tells Google to hold its tags for up to half a second waiting for an update before it sends the first hit under the default. The collector loads once the page is interactive and decides as soon as it has read stored state, so the window comfortably covers a returning visitor whose grant is already in their browser.

The value is a trade in both directions. Too low, and a returning visitor's stored grant lands after the first hit already went out as denied. Too high, and every page delays its measurement for visitors who will never grant. Five hundred milliseconds is Google's documented default, and it holds because it covers the collector's timing with room to spare.

Consent Mode state does not survive a navigation: every page starts at the host's denied default. So the collector replays a stored decision to gtag once per page, on load, rather than only when a decision changes. That replay exists because of a real miss — before it, a visitor who accepted last week was reported to Google as denied on every page after the one they clicked on, and their hits went out as cookieless pings on every property running Google tags.

Picture this

A visitor accepts in the notice on Monday. On Wednesday they come back and land on a guide, and that page starts at denied, like every page does. Once the page is interactive, the collector reads the decision already in their browser and replays it to gtag as an update. The half-second Google holds its tags for is there so that replay can land before the first hit goes out.

React 19 moves an async script, so the loader is injected instead

React 19 treats an async script element as hoistable and moves it into the head, ahead of the inline bootstrap rendered beside it. Render the loader as JSX and the ordering the whole install depends on would hinge on dataLayer buffering alone — and the page would show the loader above the denial, which is the arrangement the honesty guard forbids.

So the loader is created imperatively from a second inline script, the shape Google's own analytics.js snippet used for a decade. It creates the element, marks it async, sets the source and appends it to the head, then pushes gtag('js') and gtag('config') into the dataLayer the bootstrap already opened. gtag.js drains that queue in push order whenever it arrives, so the consent default is processed before the first page view however fast or slow the network is.

Both are plain inline scripts rather than a script component with a loading strategy. The bootstrap has to execute synchronously during HTML parse, before anything of Google's and before the collector's bundle can decide. A loading strategy is an instruction about when to fetch; this is a requirement about where in the document the code sits.

The collector only calls a gtag your page already defined

Runner's collector bridges every consent decision into Consent Mode by calling gtag with a consent update — and it only ever calls a gtag the host page already defined. It never creates one and never pushes to dataLayer directly, because inventing a gtag on a page that has none would leave a stub the real gtag.js may or may not adopt. A page with no Google tags is left completely untouched.

That contract is what makes the mount order load-bearing: bootstrap, then loader, then collector. Mount the collector first and the bridge finds no gtag, returns, and reports nothing. The notice governs the collector alone while GA4 runs beside it ungated — every piece individually working, and no error anywhere. An earlier install on one of our own sites shipped exactly that, inverted: GA4 loading for every visitor while the consent surface was held back. It looked gated from the outside. This install exists so that never repeats.

The collector sends an update and never a default, for the same reason the host's default has to come first. A default that arrives after the page has loaded is ignored, so a default from the bundle would silently grant nothing — and the host page would still own the one that counts.

In one line

Bootstrap, then loader, then collector. The collector only calls a gtag the page already defined, so if it arrives first there is nothing to call, and nothing tells you.

Unset means absent, not default-on

When the measurement ID is not set, nothing of Google's renders — no bootstrap, no loader, no config call. A preview deployment or a local run makes no request to Google rather than reporting its traffic into the production property. A blank value counts as unset, because an emptied dashboard variable means off, not broken. A malformed ID is refused before it becomes markup, so a bad paste costs a string test instead of a request that measures nothing on every page load.

One stream, and it is this site's. The marketing site is where the visitors whose consent is being asked actually are. The signed-in application gets no second stream: its operators already accepted terms, and product telemetry belongs in Runner's own collector, not in Google.

Check the order yourself, from outside

None of this needs taking on trust.

View the source of the home page and find three things in this order: the inline script with the id ga4-consent-default, the inline script with the id ga4-gtag that carries the googletagmanager.com loader address, and — only once the page has loaded — the collector's script element with the id runner-web-intent-collector. The first two are in the HTML as served. The third is added by the page after it becomes interactive, so it shows in the element inspector and not in view-source.

From a terminal, the line this repository documents for the collector is below. It prints the collector's address when the collector is configured and nothing when it is not. An absent script is invisible, so no events arriving and the variables being unset look identical from outside, and this is how you tell them apart.

One thing will look wrong and is not. Near the top of the head there is a preload link for the collector's address, placed before the bootstrap. That is a fetch hint the framework emits for a script it will add later: it downloads bytes and runs nothing, and the collector's code still executes after the bootstrap has defined gtag.

The same three checks are the honesty guard in the repository. The bootstrap's position in the rendered layout is asserted to come before the loader's; the bootstrap element is asserted to precede the collector's in the live document; and the word granted is asserted absent from the constant, the script and the whole layout. A failure there reads as the page having started measuring people it was not allowed to measure, never as a stale test.

  • curl -s https://www.runtheworld.ai/ | grep -o 'embed/v1/c/[^"]*'
What is enforced in production

One system for the whole path

The mechanism above is one of the jobs Runner does on one record, under one login. Access is by application.