Web analytics has an awkward shape. Writes arrive constantly, one per pageview, from everywhere. Reads are rare but expensive — somebody opens a dashboard and wants twelve months summarised six ways in under a second.
Building that on Cloudflare's free tier is a genuinely interesting constraint, and taking the constraint seriously produces a design that is better than the unconstrained version rather than a compromised one.
Do the aggregation on write
The naive design stores one row per pageview and aggregates when the dashboard opens. It works beautifully with ten thousand rows and falls over at ten million, because every dashboard load scans a table that grows with your traffic.
Invert it. When a pageview arrives, increment a handful of pre-grouped counters: this day, this path, one more; this day, this country, one more. The dashboard then reads a table that grows with distinct values per day rather than with traffic. A busy month and a quiet month load in the same time, because they have the same number of distinct paths.
The cost is that pre-grouped counters cannot answer questions crossing two dimensions. "Mobile visitors from Germany" needs the two facts in the same row, and they are in different rows. That is a real limitation and the correct response is a second tier — keep row-level records too, but bound them by a retention window and make them switchable per site, because that is the expensive tier and most sites never need it.
Unique visitors force a single point of serialisation
Here is the part that is not obvious, and it is the reason a naive Workers analytics tool hits a ceiling.
Two edge locations each see "one visitor". Are those one person or two? Neither location can answer, because neither has seen the other's request. Counting uniques correctly requires somewhere that sees both.
You have three options.
Send everything to the database and deduplicate there. Simple, and it makes D1 the bottleneck for every pageview. This is why several free Workers analytics projects document a ceiling of roughly twenty thousand visits a day — not a tuning problem, a structural one.
Approximate. HyperLogLog and similar give you a good estimate cheaply. Fine until somebody compares your number to a number that is exact.
Use a Durable Object. Cloudflare guarantees exactly one instance per named object globally. Route every pageview for a site to that site's object and you have a single place that sees all of them.
The third is the one worth paying for, and having paid for it, three more things arrive free:
- Session stitching, because the object already holds recent visitor state in memory
- Write batching, because it can accumulate deltas and flush every fifteen seconds instead of writing per pageview — turning a thousand pageviews into one database write
- Real-time, because "who is here now" is already in memory and costs no database reads at all
That is one architectural decision paying for four features.
The visitor must never wait
The rule that matters most operationally: the visitor's browser must never be waiting on your aggregation.
A Durable Object can be cold. It can be slow. It can be in another continent. None of that may hold open a connection on somebody's website. So the collector resolves everything it needs at the edge, replies immediately, and hands the work off with waitUntil.
If the aggregator is having a bad day, pageviews queue and eventually flush. Nobody's site gets slower. Get this backwards and your analytics becomes a availability risk for every site that installs it.
The free tier maths
D1 gives you 100,000 writes a day. That sounds tight for analytics until you notice the batching.
With per-pageview writes: 100,000 pageviews a day, and that is your lot. With a Durable Object accumulating deltas and flushing every fifteen seconds, a site writes at most a few rows per flush regardless of how many pageviews arrived — so the write budget scales with distinct values, not traffic. A million pageviews a month fits comfortably. The full cost breakdown is worth reading before assuming otherwise.
Workers give 100,000 requests a day free, which is the real constraint at scale, and Durable Objects have their own limits on the free tier.
What to store, and what not to
Never store the raw IP address. Hash it with the user agent and a per-site salt that rotates daily, use the result to stitch sessions within the day, and discard the inputs. The mechanism and its trade-offs.
Store aggregates forever. They hold no personal data and they are tiny.
Bound row-level facts. A retention window plus a daily cron to prune, or the free tier disappears into a table nobody queries.
The thing you get for being here
The reason to build on Workers rather than anywhere else is not price. It is that you can put code in front of the site — the same Worker that serves your analytics can sit in your website's request path and count the HTML request itself.
That makes blocked scripts and AI crawlers visible, which no hosted analytics can do at any price. What that changes.
If you are going to build this, build that part. It is the only part somebody cannot buy.
Common questions
Why use a Durable Object for analytics instead of just writing to D1?
Because counting unique visitors requires one place that sees every pageview for a site, and edge locations cannot see each other. Routing through D1 makes the database a bottleneck on every request, which is why several free Workers analytics projects cap out around twenty thousand visits a day. A Durable Object gives you that single point plus session stitching, write batching and in-memory real-time for free.
Can analytics on Cloudflare Workers stay inside the free tier?
For most sites, yes, but only if you aggregate on write rather than storing a row per pageview. D1 allows 100,000 writes a day; with per-pageview writes that is your hard ceiling. Batching deltas through a Durable Object and flushing every fifteen seconds makes the write budget scale with distinct values per day rather than with traffic, which changes the maths entirely.
What is the hardest part of building analytics on Workers?
Making sure the visitor never waits. A cold or slow Durable Object must never hold open a connection on somebody else's website, so the collector has to resolve everything at the edge, reply immediately, and hand the aggregation off with waitUntil. Get that backwards and your analytics becomes an availability risk for every site that installs it.