Counting Visitors Without Cookies on a Cloudflare Worker

Most small sites do not need a full analytics suite. They need four numbers: are people reading, which pages, where do they come from, and have the search engines shown up. You can get them from a Cloudflare Worker with no cookies, no consent banner and no IP addresses stored, in about 100 lines. This is the design we run in production, and how to test it.
What it stores
Only sums per day, in one small object. For each request that counts, the code adds to counters:
hvandbv: page views by people and by bots.paths,units,refs,cc: the most viewed paths, sections of the site, referrer hosts and country codes, for people only.bots: how many page views each known crawler made.api: calls to API routes, keyed likePOST /v1/embed 402.meta: who fetchedrobots.txt,sitemap.xml,llms.txtor the feed.
Every map has a cap (for example 60 paths and 40 referrers). Past the cap, new keys fall into one "other" bucket, so a crawler that requests 500 different URLs cannot make the record grow forever.
Step 1: classify the request
A tiny pure function decides what a request is. Static files, the admin area and media return null and are not counted:
export function classify(method, path, status) {
if (/^\/(admin|painel|media|assets)(\/|$)/.test(path) || /\.(css|js|png|jpg|webp|svg|ico|woff2?|mp3|wav|map)$/i.test(path)) return null;
if (path.startsWith("/v1/") || path === "/openapi.json" || path.startsWith("/.well-known/")) return "api";
if (["/robots.txt", "/sitemap.xml", "/llms.txt", "/feed.xml"].includes(path)) return "meta";
if (method === "GET" && status === 404) return "n404";
if (method === "GET" && status >= 200 && status < 400) return "page";
return null;
}
Bots are recognized by User-Agent: a list of named crawlers first, then a generic pattern for anything that looks automated.
const KNOWN = [[/googlebot|google-inspectiontool/i, "Google"], [/bingbot|bingpreview/i, "Bing"], [/yandex/i, "Yandex"], [/gptbot|oai-searchbot/i, "OpenAI"] /* ... */];
const GENERIC_BOT = /bot|crawl|spider|slurp|preview|fetch|curl|wget|python|go-http|headless|lighthouse|monitor|x402|agent/i;
export function botName(ua = "") {
if (!ua) return "no-UA";
for (const [re, name] of KNOWN) if (re.test(ua)) return name;
return GENERIC_BOT.test(ua) ? "other bot" : ""; // "" means: looks like a person
}
Two details that matter. Skip your own checks, or you will count yourself: we ignore requests whose User-Agent contains autopilot-check, and use that marker in every curl we run against production. And treat the numbers as a heuristic, because a headless browser that claims to be Chrome will be counted as a person.
Step 2: buffer in the isolate, send in batches
Calling a Durable Object on every request would waste the free request allowance exactly when a crawler floods you. Instead, each isolate keeps a small buffer and sends it as one call after 100 records or 12 seconds, whichever comes first. The work happens after the response, through ctx.waitUntil, so the visitor never waits:
let buf = [], timer = false;
const flush = async (env) => {
timer = false; const batch = buf; buf = [];
if (batch.length) { try { await counter(env).hitMany(batch); } catch { /* never break the site for a counter */ } }
};
export default {
async fetch(req, env, ctx) {
const res = await app.fetch(req, env, ctx);
const hit = record(req.method, req.url, res.status, req.headers, req.cf); // null when it should not count
if (hit) {
buf.push(hit);
if (buf.length >= 100) ctx.waitUntil(flush(env));
else if (!timer) { timer = true; ctx.waitUntil(new Promise((r) => setTimeout(r, 12_000)).then(() => flush(env))); }
}
return res;
},
};
The country comes from req.cf.country, which Cloudflare provides for free. The referrer is reduced to its host name, and referrers from your own domain are dropped.
Step 3: a Durable Object holds the day
The object keeps today's aggregate in memory, adds each record, and writes it to storage at most every eight seconds, one key per day (t:2026-10-02). Keys older than 45 days are deleted when the day changes:
async hitMany(recs) {
const day = new Date().toISOString().slice(0, 10);
if (this.day !== day) { await this.flush(); this.day = day; this.data = (await this.ctx.storage.get(`t:${day}`)) || {}; /* prune old days here */ }
for (const rec of recs.slice(0, 200)) addHit(this.data, rec);
this.dirty = true;
if (Date.now() - (this.flushedAt || 0) > 8000) await this.flush();
}
If the object is evicted between writes you lose at most the last few seconds, which is fine for a visitor count. Use a SQLite-backed Durable Object, the kind available on the free plan. Do not use Workers KV for this: its free tier allows 1,000 writes a day, as explained in Cloudflare KV Free Plan: How to Budget 1,000 Writes a Day.
What we learned from the first hours
On the first day, with a brand-new site, the counter showed what we had hoped to learn: the Yandex crawler arrived within the hour after we submitted the pages with IndexNow, a handful of requests came from other automated clients, some of them calling the paid API routes, which answer 402, and a few visits to the home page looked like people. Google had not shown up yet. Those are the facts a counter should give you: who is looking, not guesses. How we submit pages is in How to Get a New Cloudflare Workers Site Indexed.
Test it
The classification and aggregation are pure functions, so they run in plain Node with no mocks: feed in fake requests and assert the sums, assert that internal referrers are ignored, and assert that 500 different URLs from a crawler still produce at most the capped number of keys. Ours takes a few milliseconds to run.
FAQ
Does this counter use cookies or store IP addresses?
No. It stores daily sums only: page counts, referrer hosts, country codes, bot names and API call counts. No cookie is set, and no IP address or user identifier is written anywhere.
How does it tell people from bots?
By the User-Agent header. Known crawlers are matched by name (Google, Bing, Yandex, OpenAI, Anthropic and more), generic words such as bot, crawler or curl mark other automated clients, and anything else counts as a person. It is a heuristic: a headless browser that pretends to be Chrome will be counted as a person.
Why batch the writes?
A Durable Object call for every request would burn through the free plan's request allowance during a crawler spike. Each isolate buffers up to 100 records or 12 seconds and sends them in one call, and the object writes to storage at most every 8 seconds.
Can I use this without a Durable Object?
You can, but not with Workers KV: KV on the free plan allows 1,000 writes a day, which a counter would exhaust. A Durable Object with SQLite storage is the right fit.
Found this useful? Tip the studio in crypto
Every EVM chain works. USDC on Base is recommended: fees are a fraction of a cent. No account needed — it goes straight to the creator's wallet.
0x13dd72Fa0E7504790585D92bD98c720f6fD2aBa6More from DevNotes

How to Add x402 Payments to a Cloudflare Worker with Hono
A tested, minimal example of charging per request in USDC on Base with x402, Hono and Cloudflare Workers…

How an AI Agent Pays an x402 API: a 20-Line Client in JS
A working x402 client in JavaScript: sign the USDC payment, read the receipt, cap what your agent can spend…

Cloudflare Cron Triggers Not Firing? Use a Durable Object Alarm
A reliable clock for Cloudflare Workers: a Durable Object alarm that re-arms itself, with a minimum gap and a…

How to List Your x402 API on 402 Index, x402scan and Bazaar
The exact steps to get a pay-per-call x402 API discovered by AI agents: OpenAPI metadata, 402 Index, x402scan…