How to check if tracking scripts load before consent

Record Network.requestWillBeSent from a fresh profile and split the log at the moment you click Accept. On the fixture below both tags are fetched 26 ms into the load, but only one of them sends anything: collect?e=pageview leaves at 32 ms, and the second beacon waits for the click.

Why check this

Run this whenever a tag is added, whenever the consent vendor ships a new loader, and on the release candidate. A tag inventory taken from the DOM cannot answer the question, because a script element is in the document long before anyone decides whether its payload is allowed to leave.

The failure it catches is a tag that was moved behind consent in the markup and still calls home on load. The banner works, the vendor dashboard shows the consent state, and a request carrying a visitor identifier has already been sent. Two findings hide behind one symptom: a tag that loads and sends, and a tag that loads and waits. Only the first is a violation, and the request log is the only place they look different.

Prerequisites

// fixture.mjs - consent-timing fixture. Two origins, one deliberate violation each.
import { createServer } from 'node:http';

const GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
const TAG = 'http://127.0.0.1:9611';

const PAGE = `<!doctype html><meta charset=utf-8><title>Consent fixture</title>
<h1>Checkout</h1>
<div id=banner>We use cookies. <button id=accept>Accept all</button></div>
<script>
  document.cookie = '_tk_uid=u-4821; path=/; max-age=31536000';   // script cookie, pre-consent
  localStorage.setItem('_tk_uid', 'u-4821');                      // localStorage, pre-consent
  localStorage.setItem('cart_draft', '{"sku":"A-1"}');
  sessionStorage.setItem('nav_start', String(Date.now()));
  const r = indexedDB.open('analytics', 1);                       // IndexedDB, pre-consent
  r.onupgradeneeded = (e) => e.target.result.createObjectStore('events', { keyPath: 'id' });
  r.onsuccess = (e) => e.target.result.transaction('events', 'readwrite')
    .objectStore('events').add({ id: 1, e: 'pageview', uid: 'u-4821' });
  document.getElementById('accept').onclick = () => {
    document.cookie = 'consent=all; path=/; max-age=15552000';
    document.getElementById('banner').remove();
    dispatchEvent(new Event('consent'));
  };
</script>
<script src="${TAG}/tag.js"></script>
<script src="${TAG}/defer.js"></script>`;

createServer((req, res) => {                                      // first party, localhost:9610
  if (req.url === '/cart') {                                      // the function sid exists for
    const ok = (req.headers.cookie || '').includes('sid=');
    return res.writeHead(ok ? 200 : 401, { 'content-type': 'application/json' })
      .end(ok ? '{"items":1}' : '{"error":"no session"}');
  }
  if (req.url !== '/') return res.writeHead(404).end();
  res.writeHead(200, {
    'content-type': 'text/html; charset=utf-8',
    'set-cookie': ['sid=s-7f3a2b; Path=/; HttpOnly; SameSite=Lax', // strictly necessary
                   'ab=variant-b; Path=/; Max-Age=7776000'],       // header cookie, pre-consent
  });
  res.end(PAGE);
}).listen(9610, 'localhost');

createServer((req, res) => {                                      // tag origin, 127.0.0.1:9611
  const u = new URL(req.url, TAG);
  if (u.pathname === '/tag.js')                                   // sends on load
    return res.writeHead(200, { 'content-type': 'text/javascript' })
      .end(`new Image().src = '${TAG}/collect?e=pageview&uid=u-4821';`);
  if (u.pathname === '/defer.js')                                 // loads now, sends after consent
    return res.writeHead(200, { 'content-type': 'text/javascript' })
      .end(`addEventListener('consent', () => { new Image().src = '${TAG}/collect?e=consented'; });`);
  if (u.pathname === '/collect')
    return res.writeHead(200, { 'content-type': 'image/gif', 'set-cookie': 'tp_id=t-99; Path=/' }).end(GIF);
  res.writeHead(404).end();
}).listen(9611, '127.0.0.1');

console.log('fixture on http://localhost:9610/ (site) and http://127.0.0.1:9611/ (tag origin)');

Steps

  1. Step 1.

    Take the inventory a DOM audit would produce, before the banner is answered.

    // tags.mjs: the script inventory a DOM audit produces, before the banner is answered.
    import { open } from './session.mjs';
    const s = await open();
    await s.goto('http://localhost:9610/');
    await new Promise((r) => setTimeout(r, 800));
    console.log(await s.page.evaluate(() =>
      [...document.scripts].map((x, i) => `${i + 1}. ${x.src || '(inline)'}`).join('\n')));
    await s.close();
    
    1. (inline)
    2. http://127.0.0.1:9611/tag.js
    3. http://127.0.0.1:9611/defer.js

    Two third-party tags, both present before consent. This list cannot tell you which of them did anything, so it is the start of the check and not the result.

  2. Step 2.

    Log every request and split the log at the click.

    // reqlog.mjs: every request, split at the moment the Accept button is clicked.
    import { open } from './session.mjs';
    const s = await open();
    const cdp = await s.page.createCDPSession();
    await cdp.send('Network.enable');
    let t0 = null, phase = 'before';
    cdp.on('Network.requestWillBeSent', (e) => {
      if (t0 === null) t0 = e.timestamp;
      const i = e.initiator;
      console.log(`${phase.padEnd(6)} +${String(Math.round((e.timestamp - t0) * 1000)).padStart(4)} ms  ` +
        `${e.type.padEnd(8)} ${e.request.url}`);
      console.log(`              initiator: ${i.type}${i.url ? ' ' + i.url + ' line ' + i.lineNumber : ''}`);
    });
    await s.goto('http://localhost:9610/');
    await new Promise((r) => setTimeout(r, 800));
    phase = 'after';
    await s.page.click('#accept');
    await new Promise((r) => setTimeout(r, 800));
    await s.close();
    
    before +   0 ms  Document http://localhost:9610/
                initiator: other
    before +  26 ms  Script   http://127.0.0.1:9611/tag.js
                initiator: parser http://localhost:9610/ line 18
    before +  26 ms  Script   http://127.0.0.1:9611/defer.js
                initiator: parser http://localhost:9610/ line 19
    before +  32 ms  Image    http://127.0.0.1:9611/collect?e=pageview&uid=u-4821
                initiator: parser http://localhost:9610/ line 18
    before +  46 ms  Other    http://localhost:9610/favicon.ico
                initiator: other
    after  +1854 ms  Image    http://127.0.0.1:9611/collect?e=consented
                initiator: other

    Both tags are fetched at 26 ms. One beacon leaves at 32 ms, carrying uid=u-4821 in the query string. The other leaves after the click. Same markup, two different findings.

  3. Step 3.

    Resolve the line number before you quote it in a bug report.

    curl -s http://localhost:9610/ | cat -n | sed -n '17,21p'
    
        17	  };
      18	</script>
      19	<script src="http://127.0.0.1:9611/tag.js"></script>
      20	<script src="http://127.0.0.1:9611/defer.js"></script>

    line 18 in the log is the 19th line of the document. CDP counts from zero, and the position it reports is the script element, not the code that built the beacon.

  4. Step 4.

    Attribute each request to a tag by removing one tag at a time and diffing the log.

    // blocked.mjs: which tag owns which request, proved by removing one tag at a time.
    import { open } from './session.mjs';
    async function run(block) {
      const s = await open();
      const cdp = await s.page.createCDPSession();
      await cdp.send('Network.enable');
      if (block) await cdp.send('Network.setBlockedURLs', { urls: [block] });
      const seen = [];
      cdp.on('Network.requestWillBeSent', (e) => seen.push(e.request.url));
      await s.goto('http://localhost:9610/');
      await new Promise((r) => setTimeout(r, 600));
      await s.page.click('#accept');
      await new Promise((r) => setTimeout(r, 600));
      await s.close();
      return seen;
    }
    const base = await run(null);
    for (const b of ['*/tag.js', '*/defer.js']) {
      const seen = await run(b);
      console.log(`blocking ${b} removes:`);
      console.log(base.filter((u) => !seen.includes(u)).map((u) => '  ' + u).join('\n') || '  (nothing)');
    }
    
    blocking */tag.js removes:
    http://127.0.0.1:9611/collect?e=pageview&uid=u-4821
    blocking */defer.js removes:
    http://127.0.0.1:9611/collect?e=consented

    Each beacon disappears with exactly one tag. That is ownership by cause, and it holds even where the initiator field says other.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | A tag fetched before the click, nothing sent | Loaded early, silent until consent | Note it and move on. Loading a script is not itself a transfer of visitor data. | | A request to the tag origin before the click | The tag sent on load | Read the query string and the request body. This is the finding to file. | | An identifier in the pre-consent query string | A visitor value left the browser | Name the parameter in the report. uid=u-4821 above is the whole case. | | initiator: other | Chrome could not attribute the request | Use step 4. A blocked-URL diff names the owner where the initiator cannot. | | Nothing at all after the click | The tag never fires | Suspect a broken consent bridge, not a privacy win. |

Common mistakes

Sign: The script tags in the DOM are counted and the count is reported as pre-consent tracking.Cause: Step 1 and step 2 list the same two tags. One of them sent a visitor identifier at 32 ms and the other sent nothing until the click. A tag list cannot separate them, and reporting both as violations gets the real one closed as a duplicate.
Sign: The initiator column is used to name the tag that sent a beacon.Cause: Chrome reported parser http://localhost:9610/ line 18 for the pre-consent beacon, which is the position of the script element rather than the code that ran, counted from zero. The post-consent beacon came back as initiator other with no URL at all, because it was created inside an event listener. Attribution has to come from the blocked-URL diff in step 4.
Sign: The check is run on a browser profile that has already visited the site.Cause: A stored consent cookie makes the tags behave the way they do after acceptance, and the page under test never shows its pre-consent path. Every script here launches Chrome with a new profile directory, which is what makes the before column mean anything.

What to check next

FAQ

What is pre-consent tracking?

Any transfer of visitor data that happens before the visitor has answered the banner. Fetching a tag is not that transfer. The transfer is the request the tag then makes, which is why the check reads the request log rather than the script list.

Is loading a tag before consent a violation on its own?

It depends on what the tag does when it arrives. A loader that waits for a signal is defensible. A loader that opens a connection to the vendor, sets an identifier, or sends a page view is not, and the request log separates the two in one capture.

Can I run this check in DevTools without a script?

Open the Network panel with "Preserve log" ticked, load the page, and read every row before you click the banner. The script exists so the same split can run on every build and produce a diff.

Why is the second beacon 1854 ms into the load?

That is when the automated click happened, not a delay in the tag. Only the order matters: the request appears after the click and never before it. A slower or faster click moves the number and not the verdict.

Verified

Verified by Maks VernyChrome 152.0.7977.76Node 22.23.2puppeteer-core 25.10.0curl 8.21.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate11 minpublished updated Maks Verny