cloudflare

Counting unique visitors with Durable Objects

Two edge locations each seeing one visitor cannot tell whether that is one person or two. Solving that properly turns out to give you session stitching and real-time for nothing.

Counting pageviews at the edge is easy — add one, anywhere, done. Counting unique visitors at the edge is the hard problem in distributed analytics, and it is worth understanding why before looking at the solution.

The problem

A visitor in Frankfurt hits your Frankfurt PoP. Another in São Paulo hits São Paulo. Each location has seen one visitor. Your total is either one or two, and neither location can tell you which, because neither has seen the other's request.

Pageviews are commutative — you can add them up from anywhere in any order. Uniqueness is not. It requires knowing what you have already seen, which requires one place that has seen everything.

The three ways out

Deduplicate in the database. Every pageview writes a row, and uniqueness is a COUNT(DISTINCT). Correct, simple, and it makes the database a participant in every single request. This is the ceiling that free Workers analytics projects tend to hit — D1 is not built for that write rate, and the limit is structural rather than a tuning issue.

Approximate. HyperLogLog gives a good estimate in a small fixed amount of memory, mergeable across locations. Genuinely elegant, and the sketches are commutative, which is exactly the property that was missing.

The problem is not accuracy in the abstract — it is that the error is invisible and the number is compared. Somebody will hold your visitor count next to another tool's and ask why they differ by two per cent, and "it is a probabilistic estimate" is not an answer that survives that conversation.

Serialise through a Durable Object. Cloudflare guarantees exactly one live instance per named object, globally. Name it after the site, route every pageview for that site to it, and you have a single place that sees all of them.

Why the third is worth it

The Durable Object costs you a network hop from the edge location to wherever the object lives. In exchange you get exact counts — and then three more things fall out that you would otherwise have built separately.

Session stitching

The object already holds recent visitor identifiers in memory. Deciding whether this pageview continues an existing visit or begins a new one is a map lookup, not a database query. Session logic that is genuinely awkward when distributed becomes trivial.

Write batching

The object accumulates counter deltas and flushes every fifteen seconds or so. A thousand pageviews in that window become a handful of upserts.

This is the single biggest reason the free tier is viable. Without it you write per pageview and exhaust the budget in an afternoon.

Real-time, for free

"Who is on the site right now" is already in the object's memory — it is the session state. The live view costs zero database reads, which is unusual: in most analytics products the real-time view is the most expensive thing on the page.

One architectural decision, four capabilities.

Getting the details right

One object per site, not per visitor. Per visitor sounds appealing and gives you millions of objects, no batching benefit, and no place to compute a site-wide count.

Never let the visitor wait for it. The most important rule. The object can be cold, slow, or far away. The collector must resolve everything at the edge, reply immediately, and hand off with waitUntil. A cold start must never be something a stranger's browser is waiting on.

Use the alarm API to flush, not a timer in a request. Alarms survive eviction; a pending setTimeout does not.

Make the flush idempotent. Upsert with ON CONFLICT DO UPDATE, so a retry after a partial failure cannot double-count.

Hold identifiers, not identities. What lives in memory is a rotating hash — never a raw IP address. The privacy model.

Handle both collectors. If you also count at the edge, one pageview can reach the object twice — once from the HTML request, once from the script. The object is the natural place to recognise them as one event, because it is the only component that sees both. Why you would run both.

The trade you are making

A Durable Object is a single point of serialisation per site. That is the point, and it is also the limit: one very high-traffic site is bounded by what one object can process.

For the overwhelming majority of sites this is nowhere near binding, and the sharding answer when it is — several objects per site, merged on flush — reintroduces the original problem in miniature, so it should be reached for only when measured rather than anticipated.

Common questions

Why can't you count unique visitors at the edge without a Durable Object?

Because uniqueness is not commutative. Two edge locations each seeing one visitor cannot determine whether that is one person or two without communicating, and pageview counting works precisely because it does not require that. Any exact unique count needs a single place that has seen every request for the site, which is what a Durable Object provides.

Should I use one Durable Object per site or per visitor?

Per site. One per visitor gives you millions of objects, no batching benefit, and — critically — nowhere to compute a site-wide unique count, which is the whole reason for using them. Per site gives one place that sees every pageview, which is what makes exact counting, session stitching and in-memory real-time all possible at once.

What happens if the Durable Object is slow or cold?

Nothing, if the design is right. The collector must resolve everything it needs at the edge, reply to the visitor immediately, and hand the aggregation off with waitUntil. A cold start then delays a counter update by a moment and is invisible to everyone. If the visitor's browser is waiting on the object, a cold start becomes a slow page on somebody else's website.