DEV Community

Cover image for Your Cookie Banner Is Probably Lying: Trackers That Fire Before Consent, and How to Actually Block Them
Sara Casciaro
Sara Casciaro

Posted on

Your Cookie Banner Is Probably Lying: Trackers That Fire Before Consent, and How to Actually Block Them

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.

  1. Open a private window, so no previous consent is stored.
  2. Open DevTools, go to the Network tab, tick Preserve log.
  3. Load the page. Do not click anything on the banner.
  4. Type collect in 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&gtm=45je...      204     ping    js?id=G-4X8K2PQ7TZ:233
collect?v=2&tid=G-4X8K2PQ7TZ&gtm=45je...      204     ping    js?id=G-4X8K2PQ7TZ:233
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

When the visitor accepts:

gtag('consent', 'update', {
  analytics_storage: 'granted'
});
Enter fullscreen mode Exit fullscreen mode

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']
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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&gtm=45je6a...
  https://connect.facebook.net/en_US/fbevents.js
Enter fullscreen mode Exit fullscreen mode

And after the fix:

$ node consent-check.mjs https://staging.example.com
PASS: no tracking requests before consent
Enter fullscreen mode Exit fullscreen mode

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
''
Enter fullscreen mode Exit fullscreen mode

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)