· 5 min read

Counting visitors without cookies

  • Privacy
  • Analytics
  • PHP

This site has no analytics script on it. No tag manager, no pixel, no third-party font request that doubles as one. Loading a page here tells nobody but me that you were here, and it does not set a cookie to do it.

That is not a principled refusal to measure anything. I do want to know which pages people read. I just wanted to find out how far a plain server-side count would get, given that the alternative meant handing every visitor to a company neither of us has a relationship with.

How it works

Every page view inserts one row: the date, the path, the referrer, the user agent and the IP address. That is the whole design. There is no identifier assigned to the visitor, no cookie, no local storage, and nothing that follows anyone to another site — because there is no other site to follow them to.

The only interesting engineering is in when it runs. A write to the database during the request would sit between the visitor and their page, and an analytics feature that slows down the thing it is measuring is a bad trade. So it happens after the response has already been delivered:

function track_visit_deferred(): void
{
    register_shutdown_function(static function (): void {
        if (function_exists('fastcgi_finish_request')) {
            @fastcgi_finish_request();
        }
        try {
            track_visit();
        } catch (Throwable $e) {
            app_log('visits', $e->getMessage());
        }
    });
}

fastcgi_finish_request() flushes the response and lets the browser get on with rendering while PHP keeps running. The insert happens in that window. From the visitor's point of view the page has already arrived, so the measurement is free.

The try around it is not decoration. A dead database must never take down a page, and “the statistics are missing for an hour” is a vastly better outcome than “the site is down”. The same rule runs through the rest of the code: the connection helper returns null instead of throwing, and every query helper has a safe empty answer ready.

Throwing away most of the traffic

The first thing the numbers taught me is how much of the web is not people. Crawlers, link previewers, uptime monitors and scrapers are discarded before they are counted, by matching the user agent against a list:

return (bool) preg_match(
    '~bot|crawl|spider|slurp|search|bing|yandex|baidu|duckduck|facebookexternalhit|'
    . 'whatsapp|telegram|twitterbot|linkedinbot|discord|curl|wget|python|headless|'
    . 'lighthouse|pagespeed|uptime|monitor|ahrefs|semrush~i',
    $userAgent
);

An empty user agent counts as a bot too. This is a blunt instrument and I know it: it will miss a crawler that lies about itself, and one day it will catch a real browser with an unusual agent string. But a rough number I understand beats a precise one produced by a system I cannot inspect.

What this genuinely cannot tell me

The honest part. Without a cookie or an assigned identifier, there is no reliable way to know that two page views half an hour apart were the same person. So there are no sessions, no bounce rate, no funnels, no path-through-the-site, no returning-visitor figure and no attribution beyond the referrer header the browser happened to send.

The IP address is the only thing that distinguishes one visitor from another, and it is a poor proxy: a household shares one, a phone changes several in an afternoon. So “unique visitors” here means “distinct IP addresses”, which is a different and weaker claim, and the admin dashboard says so rather than dressing it up.

Why the trade was worth it

What I actually needed was: which pages get read, roughly how many people, and where they came from. All three survive. What I gave up was the ability to reconstruct one person's journey — and for a six-page site with one product on it, that was never going to change a decision I make.

The compensation is that the privacy notice can be written in plain sentences and be completely true. There is no cookie banner, because there is nothing to consent to: the only cookie this site sets is the session that protects the contact form from cross-site request forgery, and it holds nothing about you. Under South Africa's POPIA — and under the GDPR for anyone reading from Europe — the cheapest way to handle personal data correctly is to collect less of it and to share none of it.

There is a performance argument as well, and it is not small. The usual analytics tag is a third-party connection, a script download, an execution and a beacon, all competing with the content for the same first second. This costs one database insert that the visitor is not even waiting for.

If I ever need the things I gave up, the fix is not to bolt on a tracker. It is to ask a better question about what decision the data would actually change — which is the same test I apply to any dependency.

Written by the person who built it

J2Digital is one engineer in South Africa, building its own software products. More about the studio.

All posts