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

// 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

  1. 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.bin
    
    HTTP/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
    
    42

    42 bytes of GIF with no-store on it. Nothing is being cached and nothing is being displayed. The response is a receipt for a request that already did its work.

  2. 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.00

    The order number, the amount and a user id, sent to a second origin on page load.

  3. 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.ico

    215 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.

  4. 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-Origin header, which is every tracking pixel. Filtering on byte size finds nothing.

  5. Step 5.

    Count the requests again after the browser has left the page.

    node run.mjs | tail -2
    
    requests logged while the page was open: 3
    requests logged after unload:            3

    Three before, three after. As far as the page is concerned, nothing happened on the way out.

  6. Step 6.

    Read the tracker's own log for the same visit.

    cat tracker.log
    
    tracker 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.sendBeacon hands 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

Sign: A Console filter on small response bodies returns an empty array, and the page is declared clean.Cause: Resource Timing zeroes transferSize, encodedBodySize and decodedBodySize for any cross-origin response without a Timing-Allow-Origin header. In the capture above the pixel reports 0 bytes and the same-origin favicon reports 493. Size filtering is blind to exactly the requests this check is about.
Sign: The DOM has one pixel, the vendor dashboard counts two events per visit.Cause: A sendBeacon call on pagehide is not an element and is not in the markup. It fired on this fixture with the same uid and order number, and the page's own network log stayed at three requests before and after unload. Close the tab or navigate away while capturing at the server, or the second event is invisible.
Sign: A pixel is found in the source but never in the network log.Cause: It is inside a <noscript> block. Chrome does not fetch those images while scripting is on, so a browser capture is silent about them, and they still reach every reader whose browser blocks scripts.

What to check next

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.

intermediate12 minpublished updated Maks Verny