Open almost any small business website in a fresh incognito window, don't touch the cookie banner, and open DevTools. On a surprising number of them, the banner is still waiting for your answer while the analytics tag has already reported your visit.
The banner is there. The "Reject all" button is there. The privacy policy is there. And none of it matters, because the tracking happened in the first 400 milliseconds, before the banner even finished rendering.
This is not usually malice. It is an ordering problem, and it is one of the most common gaps between a site that looks compliant and one that is.
The two-minute test
You don't need any tooling beyond the browser.
- Open a private window, so no previous consent is stored.
- Open DevTools, go to the Network tab, tick Preserve log.
- Load the page. Do not click anything on the banner.
- Type
collectin the Network filter.
On a leaky site, this is what shows up before you have made any choice:
Name Status Type Initiator
collect?v=2&tid=G-4X8K2PQ7TZ>m=45je... 204 ping js?id=G-4X8K2PQ7TZ:233
collect?v=2&tid=G-4X8K2PQ7TZ>m=45je... 204 ping js?id=G-4X8K2PQ7TZ:233
The requests go to region1.google-analytics.com/g/collect, the status is 204 No Content, and the initiator is the analytics script itself. That is a page view being sent without consent.
Now go to Application → Storage → Cookies and look at the site's own domain:
Name Value Domain Expires
_ga GA1.1.1482207634.1727771152 .example.com 2027-11-06
_ga_4X8K2PQ7TZ GS1.1.1727771152.1.0.1727771152 .example.com 2027-11-06
_fbp fb.1.1727771152901.8841092773 .example.com 2026-12-31
Or, faster, in the Console:
> document.cookie
'_ga=GA1.1.1482207634.1727771152; _ga_4X8K2PQ7TZ=GS1.1.1727771152.1.0.1727771152.0.0.0; _fbp=fb.1.1727771152901.8841092773'
Three tracking identifiers, written before the visitor said yes. If your banner claims to block non-essential cookies until consent, this output says otherwise.
Why it happens
The pattern behind almost every leak is the same. The page <head> looks like this:
<head>
<!-- added by marketing, months ago -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-4X8K2PQ7TZ"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-4X8K2PQ7TZ');
</script>
<!-- added later, by someone else -->
<script src="/js/cookie-banner.js" defer></script>
</head>
The analytics tag runs as soon as it downloads. The banner script is deferred, so it runs after the document is parsed, by which time the page view is already gone. Some banner libraries try to fix this after the fact by deleting cookies once they load. That removes the evidence from the cookie jar, but the network request has already left the building. You can't un-send a hit.
The second common cause is embeds. A YouTube iframe or a Google Maps embed in the footer sets its own cookies and makes its own requests the moment the iframe loads, regardless of what your banner says.
Fix 1: tags don't execute until they're allowed to
The most robust pattern is also the oldest: don't give the browser executable scripts until consent exists. Mark them as inert data instead.
<script type="text/plain" data-consent="analytics"
data-src="https://www.googletagmanager.com/gtag/js?id=G-4X8K2PQ7TZ"></script>
A <script> with type="text/plain" is parsed but never executed. When the visitor accepts a category, you swap it for a real one:
function enableCategory(category) {
document
.querySelectorAll(`script[type="text/plain"][data-consent="${category}"]`)
.forEach((inert) => {
const live = document.createElement('script');
if (inert.dataset.src) {
live.src = inert.dataset.src;
live.async = true;
} else {
live.textContent = inert.textContent;
}
inert.replaceWith(live);
});
}
// called by the banner's "Accept analytics" button,
// and on page load if a stored choice already exists
enableCategory('analytics');
Note that simply changing the type attribute of an existing inert script does not make the browser run it. You have to insert a new element. This is a classic bug in hand-rolled consent code: the attribute flips, nothing executes, and someone "fixes" it by removing the text/plain entirely.
Fix 2: if you use Google tags, set the defaults first
If you rely on Google Tag Manager or gtag.js, Consent Mode gives you a way to declare the default state before any tag runs. The critical detail is order: this snippet must execute before the tag library loads.
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
wait_for_update: 500
});
</script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-4X8K2PQ7TZ"></script>
When the visitor accepts:
gtag('consent', 'update', {
analytics_storage: 'granted'
});
You can verify the sequence directly in the console:
> dataLayer.slice(0, 3)
(3) [Arguments(3), Arguments(2), Arguments(2)]
0: Arguments(3) ['consent', 'default', {…}]
1: Arguments(2) ['js', Fri Oct 02 2026 10:12:07 GMT+0200]
2: Arguments(2) ['config', 'G-4X8K2PQ7TZ']
If consent default is not the very first entry, your defaults arrived too late.
One thing worth understanding before you ship this: Consent Mode has two implementations. In the basic setup, Google tags are not loaded at all until consent is given. In the advanced setup, the tags load immediately and, while consent is denied, send cookieless pings instead of full hits. Whether cookieless pings are acceptable for your case is a legal and policy decision, not a technical one, so make it deliberately rather than inheriting it from a template.
Fix 3: embeds get a facade
For YouTube and Maps, the cleanest answer is to not load the iframe until the visitor asks for it. Render a static placeholder, a thumbnail image and a play button, and only inject the real iframe on click:
document.querySelectorAll('.video-facade').forEach((el) => {
el.addEventListener('click', () => {
const iframe = document.createElement('iframe');
iframe.src = `https://www.youtube-nocookie.com/embed/${el.dataset.id}?autoplay=1`;
iframe.allow = 'autoplay; encrypted-media';
iframe.title = el.dataset.title;
el.replaceWith(iframe);
});
});
The privacy-enhanced domain alone is not a full solution, but combined with click-to-load it means nothing third-party happens until the visitor explicitly wants the video. As a side effect, the page gets noticeably faster, since a YouTube embed pulls in a substantial amount of JavaScript on its own.
Don't forget the "Reject" path
A banner can block perfectly and still fail on design. European regulators have been consistent that refusing must be as easy as accepting. Italy's data protection authority, the Garante, published cookie guidelines in 2021 that treat closing the banner with an X as a refusal. A banner whose "Reject" option is hidden behind a second screen, while "Accept" is a big coloured button, is asking for trouble regardless of how good the blocking code is.
The same goes for re-opening the choice. There should be a persistent way for the visitor to change their mind, typically a link in the footer, and changing from "granted" to "denied" must actually stop future hits, not just update a checkbox.
Make the test automatic
Manual checks catch the problem once. A tiny automated check catches it every time someone changes the header. Here is a minimal Playwright script that loads the page with no stored consent and fails if any request reaches a known tracking host before the banner is touched:
// consent-check.mjs
import { chromium } from 'playwright';
const TRACKERS = [
'google-analytics.com',
'googletagmanager.com/gtag',
'connect.facebook.net',
'doubleclick.net',
];
const browser = await chromium.launch();
const context = await browser.newContext(); // clean profile, no consent stored
const page = await context.newPage();
const leaks = [];
page.on('request', (req) => {
const url = req.url();
if (TRACKERS.some((host) => url.includes(host))) leaks.push(url);
});
await page.goto(process.argv[2], { waitUntil: 'networkidle' });
await browser.close();
if (leaks.length) {
console.error(`FAIL: ${leaks.length} tracking request(s) before consent`);
leaks.forEach((u) => console.error(' ' + u.slice(0, 90)));
process.exit(1);
}
console.log('PASS: no tracking requests before consent');
Run against a leaky page, the output is unambiguous:
$ node consent-check.mjs https://staging.example.com
FAIL: 3 tracking request(s) before consent
https://www.googletagmanager.com/gtag/js?id=G-4X8K2PQ7TZ
https://region1.google-analytics.com/g/collect?v=2&tid=G-4X8K2PQ7TZ>m=45je6a...
https://connect.facebook.net/en_US/fbevents.js
And after the fix:
$ node consent-check.mjs https://staging.example.com
PASS: no tracking requests before consent
Wire it into the deployment pipeline with a non-zero exit code on failure, and the "someone pasted a pixel into the header" scenario gets caught before it reaches production instead of six months after. The list of hosts will need maintenance as tools change, but that is a far smaller job than rediscovering the leak by hand.
Verify it the same way you found it
After the fix, repeat the two-minute test exactly. With the banner untouched, the Network filter for collect should be empty, and the console should show:
> document.cookie
''
Or only your own strictly necessary cookies, such as a session cookie or the stored consent preference itself. Then click "Accept analytics" and watch the first collect request appear. That transition, nothing before the click and tracking only after it, is the entire point.
Add this check to your release routine. Consent setups rarely break on the day they're built. They break six months later, when someone pastes a new pixel into the header without knowing there was an order to respect.
Written by Sara Casciaro, founder of Sabriel Agency, digital studio in Ugento (LE), Italy.
Top comments (0)