Home / Blog / Building a Shared Pomodoro Timer on Cloudflare Durable Objects

I wanted a Pomodoro timer for co-working sessions: I start a 25-minute block, send a link, and whoever joins sees the exact same countdown I do, without a login, without a database, without a build step. In this post I will argue that the last requirement (the same countdown, not just the same settings) is the whole design problem. It marks the difference between a timer app and a shared timer app, and it rules out most of the places I would normally reach for.

Start with one file #

The timer itself is unremarkable on purpose: a single index.html with inline CSS and JavaScript, no framework, no bundler. Pomodoro, short break, long break, and a custom-minutes field, all backed by one setInterval that recomputes from a stored end timestamp rather than counting down tick by tick (the usual fix for a tab getting throttled in the background and the countdown drifting). The bell on completion is three synthesized sine tones through the Web Audio API, so there is no audio file to ship and no license to check. Settings (custom times, tone, volume) live in a cookie. No login, because there is nothing to log into.

That part could sit on any static host and be done. Netlify, GitHub Pages, Neocities; any of them would serve that file happily and for free.

The part that needs a server #

Sharing is where "static" stops being enough. A host's browser and a guest's browser are two different processes on two different machines; the only way they see the same number is if something in between holds the current state and pushes updates. That something has to be a single, addressable, long-lived place, which is exactly what serverless functions are not. A Vercel or Netlify function spins up per request with no memory of the last one; a host's "pause" and a guest's page load might not even land on the same instance.

Cloudflare's Durable Objects address precisely this shape of problem: one object instance per key, guaranteed unique globally, that can hold state in memory and keep connections open. I mint a session ID from the host's local Unix time (base-36 encoded, so 1257894000 becomes something like j5z9k0), and that ID becomes the Durable Object's name. The host's browser POSTs its state (label, total duration, whether it is running, milliseconds remaining) to /state/<id> on every start, pause, and mode change. Guests open /events/<id> as a Server-Sent Events stream and receive the same payload the instant it changes.

The one wrinkle is joining late. If a guest opens the link 40 seconds into a running countdown, they should not see the timer as it was 40 seconds ago. The Durable Object stamps every incoming state with Date.now(), and on each new connection or broadcast it subtracts the elapsed time from remainingMs before sending. The design needs no clock synchronization between host and guest; it needs one shared reference point (the server's own clock), applied once per update.

snap() {
  const st = { ...this.state };
  if (st.running) st.remainingMs -= Date.now() - this.at;
  return enc.encode(`data: ${JSON.stringify(st)}\n\n`);
}

That snippet is most of the mechanism. The Worker itself just routes two paths to the Durable Object; the Object holds a Set of open SSE writers and calls .write() on all of them when new state lands.

Why not the obvious free hosts #

Given the constraint (one process, shared memory, connections held open), most of the usual low-cost options fall out immediately:

  • Netlify, Vercel, GitHub Pages, Neocities, xmit.co: static files only, no server-side state at all.
  • Plain Cloudflare Workers without Durable Objects: the same problem as Vercel; stateless, with no shared memory between invocations.
  • Render's free tier: this option actually works, with zero code changes, because it runs a real persistent Node process. The tradeoff is a ~30-second cold start after 15 minutes idle, and in-memory session state does not survive a spin-down.
  • srv.us: an SSH tunnel to whatever is running on my own machine. Fine for handing a link to a friend for an afternoon; wrong for anything that should survive my laptop going to sleep.

Durable Objects on the free tier land in a good spot: no cold-start penalty; limits generous enough (about 100k requests and 13k GB-seconds a day) that a timer used by a few people will not come close; and the object automatically evicts and re-creates itself, so there is no server to patch or reboot.

Getting it live #

Deploy is wrangler deploy; the first run claims a workers.dev subdomain and registers the Durable Object migration, and everything after that is a normal push. I wired a GitHub Action so pushing to main deploys on its own, with a Cloudflare API token stored as a repo secret. Pointing a domain I already own at it was a matter of putting the domain's DNS on Cloudflare and adding one route line to wrangler.toml:

routes = [{ pattern = "pomodoro.000000076.xyz", custom_domain = true }]

Wrangler creates the DNS record and certificate on deploy; no dashboard clicking required after that.

It is live now at pomodoro.000000076.xyz. Keyboard shortcuts (space to start/stop, Alt+P/S/L for the three modes, Alt+R to reset) and optional desktop notifications round it out, but the interesting part was never the timer. The interesting part was realizing that "share a link" quietly demands a server with a memory, and then picking the cheapest infrastructure actually willing to have one.

Tags : notes website cloudflare javascript

Webmentions

No webmentions yet.

Previous

Review: Critical Theory of Communication

Christian Fuchs gets genuine mileage out of reading Marcuse and Honneth against Facebook, but the book's Facebook-era horizon and its short shrift to Habermas, the thinker its own subtitle foregrounds, have not aged well a decade on.

Next

Review: Cybernetic Capitalism

I argue that Jan Overwijk's synthesis of Luhmann and the Frankfurt School gives rationalization theory a genuinely new engine, though the book asks a reader to trust a great deal of scaffolding before the payoff about conspiracy and the far right arrives.