A daily planner does most of its work when you are not looking. Your Google Calendar changes and the plan has to absorb it. A task block gets overrun and the rest of the day needs repacking. At 7am a briefing goes out. The webhook subscription that tells us your calendar changed expires weekly and has to be renewed before it lapses.
The traditional shape for this is a queue and a fleet of workers: one big system processing jobs for everyone, with locks to stop two jobs touching the same user at once. We went the other way. Every ClaroCal user gets their own Durable Object: a small, single-threaded, addressable piece of compute with its own storage and its own alarm clock, living somewhere on Cloudflare’s edge. All background work for you happens inside yours. Nobody else’s work ever runs there.
One thread per user means no locks, no “two workers picked up the same sync job” incidents, no cross-tenant anything. A whole genre of concurrency bug, the genre that only reproduces in production on a Tuesday, is simply not expressible in this design. That is the pitch. The interesting part is what it takes to keep the other half of the promise: that each of these tiny computers is off whenever it has nothing to do.
An alarm clock, not a heartbeat
The hub schedules everything through a single alarm. A Durable Object gets exactly one, so ours multiplexes: calendar sync in 40 seconds, briefing at 7am, watch renewal on Thursday all go into a small table in the object’s storage, and the alarm is set to the earliest of them. When it fires, the object wakes, runs whatever is due, sets the alarm for the next earliest entry, and goes back to sleep.
A day in the life of one user's hub. Ticks are alarms: sync, briefing, watch renewal.
Between those bursts the object is not idling with a low CPU load. It is gone: evicted from memory, its state safely in storage. The next alarm or the next request from the app rehydrates it. It is the hotel-room model of compute. The lights are on exactly while you are in the room, and the keycard by the door is the alarm.
That matters for a planner because the work is so bursty. A user’s hub might do three seconds of real work across a whole day, spread over a handful of moments the user never sees. An architecture that keeps a process warm around the clock to deliver three seconds of work is paying for silence, and the waste scales with every user who signs up and drifts away. Here, a drifted user’s object simply stops having reasons to wake. Activity checks inside the hub notice the user has gone quiet and stretch the sync schedule out; a fully idle user converges to an object that never wakes at all.
Hibernation is a discipline, not a feature
Here is the part I under-appreciated at the start: hibernation is not something you turn on. It is something you can silently lose.
The runtime will only evict an object that has nothing keeping it awake. Call setInterval anywhere in the object, and it never sleeps again: you have built a heartbeat, and every user’s hub is now running around the clock whether it has work or not. Hold open a WebSocket the naive way, same story. One convenient setTimeout in a helper library, added months later by someone (me) who has completely forgotten this constraint, quietly converts “off whenever idle” into “on forever, everywhere”. Nothing breaks. No error is thrown. The product works perfectly. The regression is invisible from the outside; the fleet just stops sleeping.
A rule that important cannot live in a comment, so it lives in CI. A test walks the hub’s source file and every library file it can pull in, and fails the build on a match for setInterval, setTimeout, or new WebSocket. Crude, static, and slightly embarrassing as computer science. Also one of the highest-value tests in the repo per line of code. The allowed vocabulary inside the hub is exactly two things: alarms for the future, short fetch bursts for the present. The test makes that sentence enforceable.
I think of it as the same move as the bundle ceiling: find the invariant your architecture depends on, then make the build fail when it stops being true. An invariant nobody checks is a hope.
Belt, meet suspenders
Alarms can misfire in odd corner cases, and an object that never wakes cannot fix itself. So a handful of account-level cron triggers run as safety nets: a liveness sweep that pokes hubs which should have woken and did not, a watch-renewal sweep for expiring calendar subscriptions, a garbage collector. They are deliberately boring, and they exist to make the failure mode “sync was late” instead of “sync silently stopped for three weeks”.
They are also deliberately few. Each cron rides a shared dispatcher rather than claiming its own trigger, so adding a new scheduled job is a code change, not an infrastructure change. The self-scheduling hubs do the real work; the crons only ask whether anyone overslept.
Takeaways
- Partition by user before you partition by job type. One single-threaded object per user deletes a whole genre of concurrency bugs. The queue-and-locks version of our sync engine would be triple the code and all of the incidents.
- Design for the idle case, not just the busy one. Long-tail users outnumber active ones in every consumer product. An architecture that does literally nothing for a quiet user is calmer to operate and kinder to the platform than one that polls on their behalf forever.
- Treat hibernation as an invariant with enemies. Any timer or held connection, anywhere in the dependency graph, is a silent regression with no error message. Ours is guarded by a grep-shaped test in CI, and I will take effective-but-inelegant over elegant-but-absent.
- Single alarm, own scheduler. One alarm multiplexing a schedule table is mildly annoying to write and completely worth it: every future job costs zero new infrastructure.
- Keep dumb safety nets. Self-scheduling systems fail quiet. A cron that asks “did everyone who should have woken actually wake?” turns a silent failure into a late one, and late is recoverable.