DEV Community

Micu Claudiu
Micu Claudiu

Posted on

Counting website visitors honestly is harder than it looks - four bugs I shipped and fixed

I needed a visitor count I could base a product decision on. Not a dashboard number - an actual number where I could say what it counted and defend it.

I set the rule first: 200 qualified sessions in 7 days, or I do not build the thing. Writing the number down before any traffic arrived is the only reason it means anything. A number you decide afterwards is one you can negotiate with.

Then I wrote the counting. It took four attempts, and each failure taught me something I had not seen written down anywhere.

Bug 1: I let the visitor choose their own identity

The first version derived a session identity like this:

const pseudonym = hmac(dailyKey, `${ip}|${userAgent}`);
Enter fullscreen mode Exit fullscreen mode

Hashing IP with user agent, keyed with a salt that rotates daily so nothing is linkable across days. It looks careful. It is broken.

The user agent is a request header. The client sends whatever it wants. So one machine, changing only that string, mints unlimited distinct "visitors":

for i in $(seq 1 300); do
  curl -A "Mozilla/5.0 (Custom $i)" https://example.com/
done
Enter fullscreen mode Exit fullscreen mode

Three hundred sessions from one laptop, none of the strings matching a known bot pattern. My kill gate was 200. I could have walked past my own decision threshold in under a minute without meaning to.

The fix is to drop the user agent from identity entirely:

const pseudonym = hmac(dailyKey, ip);
Enter fullscreen mode Exit fullscreen mode

The user agent still does useful work - it drives bot filtering. It just cannot create identity. Anything the client controls is an attribute, not an identifier.

This costs accuracy: people behind the same office NAT or carrier CGNAT now collapse into one session. I decided undercounting is the right direction to be wrong in. If a number decides whether you build something, it should err toward telling you no.

Bug 2: my own traffic was the biggest contaminant

Obvious once stated, easy to skip. During development I hit my own site constantly. Every one of those was a "visitor".

Traffic now gets graded before anything is summed:

function grade(request, url, env) {
  if (request.method !== "GET")                 return "excluded_method";
  if (/prefetch|prerender/i.test(purpose))      return "excluded_prefetch";
  if (!ua || BOT_RE.test(ua))                   return "excluded_bot";
  if (operatorIps.includes(ip))                 return "excluded_operator";
  if (url.searchParams.get("test") === "1")     return "excluded_test";
  return "counted";
}
Enter fullscreen mode Exit fullscreen mode

Two details that matter more than they look:

Grade, do not drop. Excluded events are stored with their grade, not discarded. I can see exactly how many requests were filtered as operator traffic, and why each one was. A filter you cannot audit is indistinguishable from a filter that is broken.

Prefetch is real traffic that is not a visit. Browsers and link previewers fetch pages nobody looked at. Sec-Purpose: prefetch is the honest signal, and ignoring it inflates every number you report.

My test is to attack my own counter: seven requests from my machine - a normal visit, a bot string, a test link, a prefetch - and the qualified count must stay at zero. It does.

Bug 3: consent banners were quietly deciding my denominator

I store nothing on the visitor's device. No cookies, no localStorage, no fingerprint. The pseudonym is derived per request and never sent back.

That started as a privacy preference and turned out to be a measurement decision. Under ePrivacy Article 5(3), the consent requirement attaches to storing or reading information on the user's device. Store nothing and there is nothing to consent to, so there is no banner.

Which means every visitor is counted - not only the ones who click accept.

If your analytics needs consent, your denominator is "people who agreed to be measured", and that population is not the population you think it is. It moves with banner design, geography and ad-blocker rates. I did not want my go or no-go number to be a function of how persuasive my cookie dialog was.

Bug 4: the clock started before anyone knew the site existed

This is the one I did not see coming.

I deployed, then watched the counter tick up before I had told a single person the site existed. Four qualified sessions, no referrer, inside about eleven minutes.

Certificate transparency. Every TLS certificate is published to public append-only logs, and there are scanners watching those logs specifically to find new domains the moment they appear. Some send a plain browser user agent, so a user-agent-based bot filter sees nothing unusual.

The number was small. The lesson was not: my gate was accumulating background noise, and given enough pre-launch days it would have drifted toward 200 on its own. Then I would have "passed" a test nobody took.

The fix is that counting starts at an explicit publication moment, not at deploy:

CREATE TABLE gate_anchor (
  id INTEGER PRIMARY KEY CHECK (id = 1),
  published_at TEXT NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

One row, written when the first launch post goes out. Every query filters to ts >= published_at. Before that row exists, the endpoint says so in plain words rather than reporting a number that looks like a result:

{
  "counting_from": "NOT PUBLISHED YET - the numbers below are pre-publication noise, not the gate",
  "qualified_sessions_total": 0,
  "pre_publication_sessions_excluded": 4
}
Enter fullscreen mode Exit fullscreen mode

The excluded count stays visible. Hiding it would be the same failure in a politer form.

What I would tell myself at the start

  • Anything the client controls is an attribute, not an identity. If a header can change your count, it is not counting.
  • Store nothing on the device. You avoid a banner, and your denominator stops depending on a dialog.
  • Grade, do not delete. An exclusion you cannot see is a bug you cannot find.
  • Decide when the clock starts, explicitly. Otherwise infrastructure noise decides for you.
  • Pick the direction you would rather be wrong in, and write it down. Mine is undercount, because the number gates a decision.

None of this is sophisticated. It is roughly 200 lines on a Cloudflare Worker with a SQLite table behind it. The difficulty was never technical - it was being willing to write down what the number counts, then noticing that the first three definitions did not survive contact with reality.

I am running this on tierlist.lol, a directory I am building where paid placement is labelled and organic rankings are not for sale - which only means anything if the traffic numbers underneath are ones I can defend. The counting rules and the four pre-publication timestamps are published there. Happy to go into the filtering rules or the schema if useful.

Top comments (0)