SHARKPARTY
K Kam avatar Kam

Durable Objects Are the Actor Model Wearing a Database Costume

RORBT cloudflare durable-objects architecture engineering

Everything I built in the last post—the journal, the idempotent steps, the executed_at column—protects against crashes. None of it protects against concurrency, and I knew that while I was building it, in the way you know about a dentist appointment. Two readers can still both decide an orbit is due to split; the partial unique index catches that one specific collision, but the general problem sits there untouched: an orbit’s state machine can be advanced by several requests at once, and nothing in my storage layer has an opinion about that.

This is part 5 of 10 in the RORBT field notes—dispatches from building a governance platform on Cloudflare. The whole series lives on the RORBT case study.

On k8s I know exactly what I’d have done, because I’ve done it: reach for a distributed lock. Redis SET NX PX, or a lease in etcd, or a leader-elected singleton pod. And every one of those is advisory—correctness depends on every caller remembering to take the lock, and on lease expiry being tuned correctly against your slowest operation, which is a sentence that should make you flinch, because your slowest operation is not a constant. It’s a distribution with a tail, and the tail is where the incidents live.

Durable Objects are different in a way that took me embarrassingly long to appreciate, mostly because the name is actively misleading. “Durable Object” sounds like storage. It is not storage. It is Erlang wearing a database costume.

The mental model: an actor, not a database

A Durable Object is a single-threaded execution context with an identity. You address it by name:

const id = env.ORBIT_COORDINATOR.idFromName(orbitId);   // deterministic
const stub = env.ORBIT_COORDINATOR.get(id);
await stub.settle(orbitId);                             // RPC, type-checked

idFromName is a hash—the same orbit id always routes to the same object, globally. Cloudflare guarantees exactly one instance of that object exists at a time, anywhere in the world, and that its methods do not run concurrently.

That last guarantee is the product. Everything else is packaging.

It isn’t a lock you take. It’s a property of where the code runs.

There’s no lease to expire, no NX to forget, no split-brain during a network partition. Two requests for the same orbit are serialized because they execute in the same single-threaded context—mutual exclusion not as a discipline every caller must observe, but as a fact of geography. The closest AWS analogue would be a Lambda with reserved concurrency of 1 per partition key, which doesn’t exist. The closest honest analogue is an Erlang process or an Akka actor, which is why I keep insisting on the costume framing.

graph TB
  subgraph Before["Before: inline settle"]
    R1[Request A] --> S1[settleSplit]
    R2[Request B] --> S2[settleSplit]
    S1 --> O1[(orbit R2)]
    S2 --> O1
    style S1 fill:#7f1d1d,color:#fff
    style S2 fill:#7f1d1d,color:#fff
  end
  subgraph After["After: coordinator DO"]
    R3[Request A] --> C[OrbitCoordinator<br/>idFromName orbitId<br/>single-threaded]
    R4[Request B] --> C
    C --> O2[(orbit R2)]
    style C fill:#14532d,color:#fff
  end

Alarms: the cron you don’t have to configure

The second thing DOs give you is setAlarm, and it quietly fixed a design flaw I’d been living with rather than solving. Before this, every deadline in the system resolved lazily on next access—a design forced by not having a scheduler:

  • A proposal whose 48-hour window expired stayed ACTIVE until somebody loaded the page.
  • An orbit that hit its member cap stayed over cap until someone opened the dashboard.
  • A founding window that lapsed left the founder with elevated permissions indefinitely.

I’d been telling myself this was fine because it was eventually correct. It wasn’t fine.

That’s not a governance system; that’s a governance system with a race against user attention.

A vote nobody watches is exactly the vote that most needs to resolve on time.

async settle(orbitId: string) {
  // The DO id is a hash of the name, so the orbit id cannot be recovered from
  // it. Persist it the first time we're told, so alarm() knows who it is.
  await this.ctx.storage.put('orbitId', orbitId);

  const nextAlarmAt = await this.arm(orbitId, orbit);
  return { settled: true, nextAlarmAt };
}

async alarm(): Promise<void> {
  const orbitId = await this.ctx.storage.get<string>('orbitId');
  if (!orbitId) return;
  await this.settle(orbitId);   // settling re-arms → walks a queue of deadlines
}

That storage.put('orbitId', …) line is a genuine gotcha, and I want it on the record because I lost real time to it. idFromName is one-way—given the DO id you cannot recover the name. An object that gets woken by an alarm, rather than by an RPC that hands it its identity, wakes up amnesiac. If it needs to know who it is, it has to have written it down beforehand. The actor has to keep a note in its own pocket.

Scheduling, at least, is pure and testable:

export function nextDeadline(input: DeadlineInput, now: number): number | null {
  // A split or prepare that is already due needs no clock at all.
  if (input.orbit && (shouldExecuteSplit(input.orbit) || shouldPrepareSplit(input.orbit)))
    return now;
  const due = input.openProposalCloses
    .filter(Number.isFinite)
    .map((c) => (c <= now ? now : c));
  return due.length ? Math.min(...due) : null;
}

export function clampAlarm(at: number, now: number): number {
  const MAX_AHEAD = 30 * 24 * 60 * 60 * 1000;
  if (at <= now) return now;
  return Math.min(at, now + MAX_AHEAD);   // one bad row can't schedule for 2035
}

The clamp is defensive and I’d write it again without apology. Cloudflare will happily accept an alarm years out; a mis-stamped closes_at shouldn’t turn into a permanent ghost wakeup haunting the system from 2035.

What DOs do not give you

This is the part I want to be loud about, because the marketing implies otherwise, and because I briefly believed the marketing.

Transactional storage covers the DO’s own storage only. this.ctx.storage is transactional. R2 and D1 writes made from inside a DO are exactly as non-atomic as they were outside it. My coordinator does most of its work against R2 and D1, so the entire saga machinery from the previous post remains necessary. I did not get to delete it. I got to keep it and add an actor on top.

I’ve seen people reach for DOs expecting cross-store atomicity. It isn’t there, and the confusion has a precise shape:

Serialization (DO)Atomicity (transaction)
Two callers interleavingPreventedNot addressed
Crash mid-operationNot addressedPrevented (rollback)
Partial writes visiblePossibleImpossible
Serialization is not atomicity. You need both, and they solve orthogonal problems.

A DO is a single point of contention. All traffic for one orbit funnels through one object. That’s the point—but it means a slow operation blocks every other operation for that orbit. Partitioning by orbit id is what keeps this sane: a hot orbit can’t slow down a cold one. If I’d been tempted to make one global coordinator, this is the reason not to.

Pages cannot define a DO. The class must live in a Worker, bound by script_name. So a Pages project that wants one is now a two-deployable system with an ordering constraint—the closest thing on this platform to a service dependency, arriving in a project I’d deliberately built to have none.

The cross-project import that made me nervous

The coordinator needs to run the same settle logic as the Pages functions. The obvious wrong move is to duplicate it and let the two copies drift apart at the speed of two teams’ worth of bug fixes, except I’m one person, so they’d drift at the speed of my memory. Instead the Worker imports across the project boundary:

// workers/orbit-room/src/coordinator.ts
import { settleFoundingWindow } from '../../../functions/api/_lib/governance-store';
import { settleSplit } from '../../../functions/api/_lib/split-exec';
import { loadOrbit } from '../../../functions/api/_lib/orbit-store';

Wrangler bundles it fine—wrangler deploy --dry-run produced a 265 KiB Worker with all bindings resolving. But I want to be honest about what those three lines cost: this is real coupling. Two deployables now share source, and a change to _lib requires deploying both. I stared at that import path, all those ../ hops, for a while before committing it.

It only works because _lib was already written to be runtime-light—functions that take env as an argument, no module-level side effects, type-only imports where possible. That discipline was originally for testability (post 06). It paid a second dividend here, which is the usual way with that kind of discipline: you buy it for one reason and it keeps quietly paying out for others.

Shipping it without breaking everything

The binding doesn’t exist until the Worker is deployed. Local dev shows [not connected]. The test harness has no DO at all. So the client degrades:

export async function settleOrbit(env: Env, orbit: OrbitRecord): Promise<void> {
  if (hasCoordinator(env)) {
    try {
      await orbitCoordinator(env, orbit.id).settle(orbit.id);
      return;
    } catch (e) {
      // Bound but erroring. Degrade to inline rather than leaving the orbit
      // unsettled—correctness first, serialization second.
      log.warn('coordinator settle failed; falling back to inline', { err: e, … });
    }
  }
  try {
    await settleFoundingWindow(env, orbit);
    await settleSplit(env, orbit);
  } catch (e) {
    log.error('inline settle failed', { err: e, … });
  }
}

Two deliberate choices there, and I’d defend both. It falls back when the DO is bound but failing, not just when it’s absent—losing serialization is much better than losing the settle, because serialization protects against a race that’s rare and the settle protects against a wrong answer that’s permanent. And it never throws, because settling is housekeeping riding along on someone else’s request, and housekeeping must not be able to fail the request it’s riding on.

Comparison to what I’d have built on k8s

Concernk8s approachDurable Object
Mutual exclusionRedis lock / etcd leaseFree—single-threaded by construction
Lock expiry tuningYes, and it’s a footgunN/A
Split-brain on partitionPossiblePrevented by platform
Scheduled workCronJob, or a leader-elected tickersetAlarm per entity
Per-entity schedulingAwkward—one cron scanning all rowsNatural—one alarm per object
Cost when idleA pod, alwaysZero
ObservabilityPrometheus, kubectl logsLogs only, and harder to reach

The per-entity scheduling row is the one that actually changed my design, not just my deployment. On k8s I’d have written a CronJob scanning every orbit for due deadlines every minute—an O(n) sweep, every minute, forever, to find the rare row that needs work. With alarms, each orbit schedules its own next wakeup. The work is proportional to the events, not to the data—which is the whole actor model, rediscovered on a CDN, wearing a costume that says “database” on the front. Look past the costume. The actor underneath is the best primitive on the platform.


Previous: Storage Without Transactions · Next: Testing Workers Against Real Bindings