How to check for tracking pixels
Open DevTools, Network tab, filter by Img, and look for responses under 100 bytes with Content-Type: image/gif. On the fixture below one such request carried ev=purchase&order=4417&uid=u-9f31&val=79.00 in its query string. A second beacon fired at page unload and never reached the network log at all.
Why check this
A tracking pixel is a request, not a picture. The image is a 42-byte placeholder; the payload is in the URL. That makes it the cheapest way for a page to hand an order id, a value and a user id to another origin, and the easiest thing to add without a code review noticing, because it is one <img> tag.
Run this check on a release candidate whenever marketing has touched the templates. The failure it catches is a purchase confirmation page that reports the order number and its value to a second origin before anyone agreed to analytics. A DOM search for tag names misses it, because one of the two calls here is not an element.
Prerequisites
- Node 22. Save the two files below and start each one in its own shell. The tracker listens on
127.0.0.1:9621, which is a different site fromlocalhost:9620, so the requests between them are genuinely cross-site. - Chrome 152 with DevTools, or the same page driven through the DevTools Protocol.
- The figures here are one capture on one machine on 2026-09-12.
// tracker.mjs the second origin, standing in for an analytics vendor
import { createServer } from 'node:http';
const GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
createServer((req, res) => {
const body = [];
req.on('data', (c) => body.push(c));
req.on('end', () => {
console.log(JSON.stringify({
method: req.method,
url: req.url,
dest: req.headers['sec-fetch-dest'] ?? null,
referer: req.headers.referer ?? null,
body: Buffer.concat(body).toString() || null,
}));
if (req.url.startsWith('/px.gif')) {
res.writeHead(200, { 'content-type': 'image/gif', 'content-length': GIF.length, 'cache-control': 'no-store' });
res.end(GIF);
} else {
res.writeHead(204, { 'cache-control': 'no-store' });
res.end();
}
});
}).listen(9621, '127.0.0.1', () => console.log('tracker listening on 127.0.0.1:9621'));
// site.mjs the page under test
import { createServer } from 'node:http';
const T = 'http://127.0.0.1:9621';
const page = `<!doctype html><meta charset="utf-8"><title>Order confirmed</title>
<h1>Order 4417 confirmed</h1>
<img src="${T}/px.gif?ev=purchase&order=4417&uid=u-9f31&val=79.00" width="1" height="1" alt="">
<noscript><img src="${T}/px.gif?ev=purchase&js=off" width="1" height="1" alt=""></noscript>
<script>
addEventListener('pagehide', () => {
navigator.sendBeacon('${T}/b?ev=exit', new URLSearchParams({ uid: 'u-9f31', order: '4417' }));
});
</script>`;
createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
res.end(page);
}).listen(9620, 'localhost', () => console.log('site listening on localhost:9620'));
// run.mjs drives Chrome over the DevTools Protocol and prints what it saw
import { open } from '../../scripts/browser/session.mjs';
const s = await open();
try {
const seen = [];
await s.cdp.send('Network.enable');
s.cdp.on('Network.requestWillBeSent', (e) =>
seen.push({ id: e.requestId, method: e.request.method, url: e.request.url, type: e.type }));
s.cdp.on('Network.responseReceived', (e) => {
const r = seen.find((x) => x.id === e.requestId);
if (r) { r.status = e.response.status; r.mime = e.response.mimeType; }
});
s.cdp.on('Network.loadingFinished', (e) => {
const r = seen.find((x) => x.id === e.requestId);
if (r) r.bytes = e.encodedDataLength;
});
await s.goto('http://localhost:9620/');
await new Promise((r) => setTimeout(r, 500));
console.log('--- every request the page made ---');
for (const r of seen)
console.log([r.method.padEnd(4), String(r.type).padEnd(9), r.status, String(r.mime).padEnd(10),
String(r.bytes).padStart(4), r.url].join(' '));
console.log('\n--- resource timing, as the page sees it ---');
console.log(JSON.stringify(await s.page.evaluate(() =>
performance.getEntriesByType('resource').map((e) => ({
name: e.name, initiatorType: e.initiatorType,
transferSize: e.transferSize, encodedBodySize: e.encodedBodySize, decodedBodySize: e.decodedBodySize,
}))), null, 1));
console.log('\nrequests logged while the page was open: ' + seen.length);
await s.page.goto('about:blank');
await new Promise((r) => setTimeout(r, 800));
console.log('requests logged after unload: ' + seen.length);
} finally { await s.close(); }
session.mjs launches the installed Chrome with a fresh profile. Any Chromium driver with DevTools Protocol access does the same job.
Steps
- Step 1.
Fetch the suspected pixel on its own and read what came back.
curl -sS -D - -o px.bin "http://127.0.0.1:9621/px.gif?ev=purchase&order=4417&uid=u-9f31&val=79.00" && wc -c < px.binHTTP/1.1 200 OK content-type: image/gif content-length: 42 cache-control: no-store Date: Sat, 12 Sep 2026 08:09:40 GMT Connection: keep-alive Keep-Alive: timeout=5 4242 bytes of GIF with
no-storeon it. Nothing is being cached and nothing is being displayed. The response is a receipt for a request that already did its work. - Step 2.
Read the payload out of the query string.
node -e "const u=new URL(process.argv[1]);for(const [k,v] of u.searchParams)console.log(k.padEnd(6),v)" "http://127.0.0.1:9621/px.gif?ev=purchase&order=4417&uid=u-9f31&val=79.00"ev purchase order 4417 uid u-9f31 val 79.00The order number, the amount and a user id, sent to a second origin on page load.
- Step 3.
Load the page in Chrome and list every request with its type and byte count.
node run.mjs--- every request the page made --- GET Document 200 text/html 701 http://localhost:9620/ GET Image 200 image/gif 215 http://127.0.0.1:9621/px.gif?ev=purchase&order=4417&uid=u-9f31&val=79.00 GET Other 200 text/html 701 http://localhost:9620/favicon.ico215 bytes on the wire for the pixel: 42 bytes of body and the rest headers. The
<noscript>copy of the same pixel is absent, because scripting is on. - Step 4.
Read the second section of the same run, which evaluates this expression inside the page. It is what the Console would return.
performance.getEntriesByType('resource').map((e) => ({ name: e.name, initiatorType: e.initiatorType, transferSize: e.transferSize, encodedBodySize: e.encodedBodySize, decodedBodySize: e.decodedBodySize }));[ { "name": "http://127.0.0.1:9621/px.gif?ev=purchase&order=4417&uid=u-9f31&val=79.00", "initiatorType": "img", "transferSize": 0, "encodedBodySize": 0, "decodedBodySize": 0 }, { "name": "http://localhost:9620/favicon.ico", "initiatorType": "other", "transferSize": 793, "encodedBodySize": 493, "decodedBodySize": 493 } ]Every size on the cross-origin entry is zero, while the same-origin favicon reports 793 bytes. Resource Timing hides sizes for a cross-origin response that carries no
Timing-Allow-Originheader, which is every tracking pixel. Filtering on byte size finds nothing. - Step 5.
Count the requests again after the browser has left the page.
node run.mjs | tail -2requests logged while the page was open: 3 requests logged after unload: 3Three before, three after. As far as the page is concerned, nothing happened on the way out.
- Step 6.
Read the tracker's own log for the same visit.
cat tracker.logtracker listening on 127.0.0.1:9621 {"method":"GET","url":"/px.gif?ev=purchase&order=4417&uid=u-9f31&val=79.00","dest":"image","referer":"http://localhost:9620/","body":null} {"method":"POST","url":"/b?ev=exit","dest":"empty","referer":"http://localhost:9620/","body":"uid=u-9f31&order=4417"}Two calls, not one. The second carries the same user id and order number in a POST body, and the page counted three requests both before and after unload.
navigator.sendBeaconhands the request to the browser process, which delivers it once the document is gone, so a capture scoped to the page never sees it.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| A GIF or PNG response of 35 to 50 bytes | A tracking pixel, whatever the file name says | Decode its query string and record every field. |
| Cache-Control: no-store on an image | The vendor wants every view counted | Treat it as an event call, not an asset. |
| All Resource Timing sizes are 0 | The response is cross-origin with no Timing-Allow-Origin | Read sizes from the Network panel or from the protocol, not from the page. |
| Requests in the tracker log that the browser never showed | A beacon delivered after unload | Capture at the server or with "Preserve log" on, and leave the page during the test. |
| A pixel inside <noscript> and no matching request | It fires only for readers with scripting off | Review it as live code. It is not dead. |
Common mistakes
What to check next
- How to check which third parties receive data: separates a request to another origin from a request that carries the user.
- How to check which third party scripts a page loads: the tags that inject pixels at runtime rather than in the markup.
- How to check if tracking scripts load before consent: the same capture, run before the banner is answered.
- How to check referer header: the pixel sends your page URL as well as its query string.
- How to check cookies on a website: the other identifier a pixel response can plant.
FAQ
What are tracking pixels and how do they work?
A 1x1 image whose URL carries the data. The browser requests it, the server records the query string, the Referer and the calling IP, then returns a tiny image. The picture is irrelevant. The request is the message.
How tracking pixels work when images are blocked?
The image never loads and the request is never made, so the event is lost. Vendors answer that with sendBeacon and fetch with keepalive, neither of which is an image and neither of which an image blocker stops.
Does a 204 response mean nothing was sent?
No. The status describes the reply, not the request. A 204 endpoint receives the full URL, headers and body before it answers, which is what the tracker log in step 5 shows.
Can I find every pixel by reading the HTML?
No. This fixture has one pixel in the markup and one beacon built by script at unload. Tag managers add more at runtime. The network capture is the source of truth, and it has to run past the moment the page closes.
How many pixels is too many?
The count is not the measure. One pixel that carries an order value to an unapproved origin matters more than ten that carry a page path. Read the payload of each one and judge that.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2Chrome 152.0.7977.76
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
intermediate12 minpublished updated Maks Verny