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
- Node 22 and the Chrome on this machine, driven by
npm i puppeteer-core. Theopen()launcher these scripts import is printed in full in How to check if a service worker is registered; save it assession.mjsnext to them. - Network.requestWillBeSent and Network.setBlockedURLs in the protocol reference.
- Your own fixture, never somebody else's site. A single page load does not support a public claim about a real operator, and scanning a host you do not own is out of bounds. Save this as
fixture.mjs, runnode fixture.mjs, stop it by PID afterwards. Ports 9610 and 9611 were free on this machine.tag.jssends on load,defer.jsloads at the same moment and sends only after the click.
// 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)');
- The figures below are one capture on one machine, Chrome 152 on 2026-09-12. Timings move between runs; the order does not.
Steps
- 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.jsTwo 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.
- 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: otherBoth tags are fetched at 26 ms. One beacon leaves at 32 ms, carrying
uid=u-4821in the query string. The other leaves after the click. Same markup, two different findings. - 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 18in 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. - 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=consentedEach 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
What to check next
- How to check if cookies are set without consent: the same timing split applied to the cookie jar, including cookies no script can see.
- How to check if local storage is used before consent: the pre-consent writes that never become a request.
- How to check which third party scripts a page loads: the inventory step, for a page where consent is not the question.
- How to check number of requests on a page: explains why a request log and a markup count disagree.
- How to check if a service worker is registered: prints the
open()launcher these scripts import.
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.
Related on this site
intermediate11 minpublished updated Maks Verny