This post is still being written — please check back later. Posted: May 2026.
Pingpong is the analytics platform for every site I run. It replaces Plausible, Vercel Analytics, and Google Analytics for me. The whole thing is one Cloudflare Worker, a handful of Durable Objects, and a small React dashboard built with Vite — that's it. The video above is the live dashboard at pingpong.sdan.io.
What I built it with
One Cloudflare Worker (workers/src/index.ts) handles all ingest and serves the API. The dashboard is a Vite + React + TypeScript SPA in workers/dashboard/, built and shipped as static assets from the same Worker via the Workers Assets binding. The globe is cobe, a 5KB WebGL library. Live updates ride a WebSocket using Cloudflare's Hibernation API so I don't pay for an idle connection map. That's the entire stack.
Durable Objects
The interesting part is the storage. Pingpong uses Durable Objects instead of a database, and the design is that each DO holds the smallest piece of state whose queries it owns end-to-end. There are five classes:
- `ViewCounter` — one DO per
${site}:${slug}. Just an integer per page, with IP rate-limiting. - `EventLog` — one DO per
${site}:${yyyy-mm}. SQLite-backed, indexed on the fields I actually filter by (timestamp, path, visitor_id, country, kind, colo, city, ASN). Queries across months fan out in the Worker. - `ActiveUsers` — one DO per site for presence, plus a global one keyed
__all__that aggregates across sites. Per-site DOs forward join/leave events to the global one over DO RPC. - `SiteIndex` — a single registry of every site that's ever posted an event, used to render the site list in the left rail.
- `Comments` — one DO per
${site}:${slug}, AI-moderated guestbook.
There's no migration story when I add a new site or a new month. The DO just doesn't exist yet, and the first track event creates it. Aggregation across DOs lives in the Worker, not in storage.
What's honestly not great
The ActiveUsers('__all__') DO is doing too much. It's both an aggregator and the source of truth for the global presence list, which means any schema change there forces all dashboard WebSockets to drop and clients see a blank globe for ~30s while they reconnect. The fix is to make it a broker — hold no state, just route — and rebuild presence on connect by querying the per-site DOs. I haven't done that yet.
The polling cadence is also more conservative than it needs to be. The dashboard refetches /active every few seconds even though the WebSocket already pushes joins and leaves; the polling is there as a belt-and-suspenders reconcile. Could probably be removed once I trust the WebSocket path fully.
How sites send data
Every site wires up with one script tag.
` <script defer src="https://pingpong.sdan.io/client.js" data-site="sdan.io" data-presence="true"></script> `
The script reads its data-* attributes, posts pageviews to /track, and (if data-presence is set) opens the WebSocket so the live globe and roster reflect that visitor right away. No SDK to install, no per-framework adapter. Pages on this site that say "152 from San Francisco, Campbell, Hyderabad" are calling /breakdown?slug=...&limit=3, which returns top-N reader cities for a page grouped by distinct visitor count — not raw pageview events. The distinction matters because "from San Francisco" should mean five people, not twenty-five refreshes from one person.