lucascarlson.net writing

Introducing Open Source Durable Objects

The best backend primitive of the last decade is an object with a name.

That is the whole idea behind Cloudflare's Durable Objects: one single-threaded object per identity, addressed by name, with durable state attached. A shopping cart is an object. A chat room is an object. So is a game table, a device, a document, an agent run.

Calls to one identity run one at a time, so two requests can't corrupt the same cart. Calls to different identities run in parallel, so nobody waits behind a stranger. Kenton Varda's team shipped that and deleted a whole problem the rest of us usually solve with a database, Redis, a queue, and a fistful of locks.

I have spent twenty years building that pile of locks. I have built it in Rails apps, in Node services, and once, regrettably, in a spreadsheet importer. So when I finally internalized the Durable Objects model, I got a little angry. The model is too good to live behind one vendor's edge network.

I'm not alone in thinking this. Ryan Dahl's celld recreates the model as a self-hosted daemon: your VMs, your object-storage bucket, the Workers API without Cloudflare. It's excellent, and it proves the model has outgrown its birthplace. But celld answers the question at the infrastructure altitude. With celld you complicate your infrastructure by adding nodes and buckets and monitoring and scaling this new infrastructure.

I wanted the answer one altitude down, where most of us actually live: what if Durable Objects were just a library, on the SQL database you already run?

So I built it. It's called Solid Objects, it's MIT, and it ships in two implementations that share one design: a Ruby gem that runs in production in an app with over 100,000 users, and a TypeScript package for Node.

This post is about the TypeScript one. As of this week it does something I didn't plan for when I started: the whole runtime runs inside a browser tab.

An actor is just a class

Here is the whole programming model:

import { Actor, createRuntime } from "solid-objects"
import { sqlite } from "solid-objects/database/sqlite"

class TicketSale extends Actor {
  static override readonly actorType = "TicketSale"

  remaining = 100
  holds: Record<string, number> = {}

  reserve({ buyer }: { buyer: string }): boolean {
    if (this.remaining === 0 || buyer in this.holds) return false

    this.remaining -= 1
    this.holds = { ...this.holds, [buyer]: Date.now() }
    this.schedule({ at: new Date(Date.now() + 600_000), key: buyer }).expire!({ buyer })
    return true
  }

  expire({ buyer }: { buyer: string }): void {
    if (!(buyer in this.holds)) return

    const rest = { ...this.holds }
    delete rest[buyer]
    this.holds = rest
    this.remaining += 1
  }
}

const runtime = createRuntime({
  database: sqlite({ path: "sale.sqlite3" }),
  authorizeMessage: () => true,
  authorizeQuery: () => true,
})
await runtime.install()

const sale = runtime.ref(TicketSale, "event-42")
await Promise.all([sale.reserve({ buyer: "ava" }), sale.reserve({ buyer: "kai" })])

Every reserve call lands in a durable mailbox for event-42 and runs one at a time, even when different requests, or different Node processes, fire them at once. That ordering is the whole trick: the check on remaining and the write after it can't interleave, so the sale can't oversell.

Different events run in parallel, so one busy sale never blocks another. State is just rows in SQLite, Postgres, or MySQL. An idle sale costs you those rows and nothing else. No process, no daemon waiting around behind it.

That idle-cost property is the whole pitch. Cloudflare gives you this model as a managed platform. celld gives it to you as a fleet. Solid Objects gives it to you as a dependency in package.json. There is nothing new to operate, because you are already operating the only thing it needs.

The ten-minute hold is a durable reminder. schedule keyed by the buyer arms one alarm per hold; schedule the same key again and it moves that buyer's alarm instead of piling on a second. The alarm lives in the same database as the state, so it survives a deploy or a crash. No cron sweeper, no expires_at column, none of the races between the two.

That is the part a counter can't reach. A guarded write, a per-item timer, and ordered concurrency, all in one class. The stuff you'd otherwise glue together from a column, a background job, and a lock, then debug on a Friday night.

You can try the claims with a single command:

npm exec --yes --package=solid-objects@latest -- solid-objects quickstart

It fires 25 concurrent calls at one identity and checks that they serialized to a final state of 25, with the complete return sequence 1 through 25, while unrelated identities overlapped freely. Every check is an assertion; the command exits non-zero when one fails. I did that on purpose. I want the marketing to be something you can prove wrong.

How a turn commits

The correctness core is small enough to hold in your head. In pseudo-code, one turn looks like this:

# what happens when you call sale.reserve({ buyer })

insert durable message "reserve on event-42"    # survives a crash from here on

worker claims TicketSale "event-42" (one lease) # one worker at a time
state  = load(TicketSale, "event-42")
result = state.reserve(buyer)                   # your code runs here,
                                                # outside any transaction
transaction do                                  # one atomic commit:
  assert the lease is still valid               #   a stale worker fails here
  save the new state                            #   remaining and holds
  save everything the handler staged            #   the 10-minute expiry alarm
  mark the message done
end

reply to the caller with result

An attempt may run more than once. Only one attempt ever commits.

A call becomes a durable message in the actor's mailbox. A worker claims the actor under a lease with a fencing token. Your handler runs outside any database transaction, so a slow handler never holds a lock on anything.

When the handler returns, one fenced transaction commits the new state together with everything it staged: outbound messages to other actors, scheduled reminders, external effect intents. And if the lease went stale while the handler was running, that commit is rejected inside the very transaction that would have written it.

Delivery is at-least-once with strict per-identity order. External effects can run twice, so they carry a stable effect id and you make them idempotent. I will not pretend this is exactly-once, because nothing is, and the systems that claim otherwise are describing their happy path.

Last week a stranger challenged the fencing claim in the sharpest way I have seen it put. Death is easy to handle, they argued; the dangerous case is the holder that does not die. A worker claims an actor, hits a long GC pause, loses its lease, a second worker takes over and commits, and then the first worker wakes up and tries to land its stale write. If the fence check and the write are two steps, the late write wins and your history forks.

So I ran exactly that. Two worker processes, a 250ms lease, and a handler that synchronously blocks the event loop for 2.5 seconds, which freezes lease renewal the same way a GC pause would. The observed timeline:

t+0ms     worker A claims the message, stalls
t+261ms   lease expired; worker B claims, executes, commits
t+2500ms  worker A wakes, finishes its handler, attempts its commit
final     state contains attempt 2 only; A's write is fenced out

The late write never landed, in any run, because the fence re-check lives inside the commit transaction. This is the property everything else in the system leans on, and it is why I am comfortable putting the word "solid" in the project name.

Where that challenge came from is my favorite part of this whole launch. I posted the project on a forum where the participants are AI agents, told them to break it, and an agent that builds settlement systems came back with three failure probes, ranked by where this kind of model historically cracks.

The stall test above was its first probe. The third one, two independent recoveries from the same database snapshot replaying every mailbox in identical order, passed too. The second was a good enough idea that I hadn't built it yet, so it's an open issue now. Turns out adversarial review is the only marketing I trust.

The whole runtime runs in the browser

Then the project outgrew its own pitch.

Version 0.14 runs the complete runtime, the same mailbox, leases, fencing, reminders, and effects, inside a browser module worker. The database is SQLite compiled to WASM. Durable storage is OPFS, the browser's origin-private file system, so committed actor state survives page reloads and browser restarts. Actors look exactly like they do in Node:

import { Actor, configure, sharedSqliteWasm } from "solid-objects/browser/host"

class Counter extends Actor {
  static actorType = "Counter"

  count = 0

  increment({ amount = 1 } = {}) {
    this.count += amount
    return this.count
  }
}

const runtime = configure({
  database: sharedSqliteWasm({ path: "app.db" }),
  authorizeMessage: () => true,
  authorizeQuery: () => true,
})
await runtime.install()

await Counter.ref("page-hits").increment()

That code runs identically in every tab of the origin, and the hard engineering lives right here. Browsers give you no process supervisor, so the runtime builds one out of web primitives.

The Web Locks API elects one database holder per origin. Every other tab forwards its SQL to the holder over a BroadcastChannel. When the holder's tab dies, the lock releases, the next tab promotes itself, and the runtime picks up from the same OPFS state. The leases and fencing that arbitrate Node processes arbitrate your browser tabs, unchanged.

You don't have to take my word for any of it. The homepage runs the runtime on the page. The counter there is a durable actor committed to SQLite WASM in your own browser, and your page view was itself a committed actor call. Reload it; the count survives. Open the page in a second tab, close the one holding the database, and watch the other take over the same state.

And if you want to try it without installing anything at all, one import in a module worker works from a CDN, wasm and all:

import { Actor, configure, sharedSqliteWasm }
  from "https://esm.sh/solid-objects@latest/browser/host"

Offline writes drain into Node, or into Rails

A durable browser actor raises an obvious question: what happens when it needs to reach the server? The answer is the transmit family. An actor stages an outbound call in the same transaction as its own state change, with one extra line:

class Counter extends Actor {
  static actorType = "Counter"

  count = 0

  increment({ amount = 1 } = {}) {
    this.count += amount
    this.transmit().increment({ amount })  // staged in the same commit
    return this.count
  }
}

Because the intent commits with the state, a crash can never leave you with a local write the server will never hear about, or a server call for a write that rolled back. A drain worker then delivers each envelope with at-least-once delivery and per-actor order, and you supply the transport. Throw while offline and the effect retries with backoff:

registerTransmit({
  runtime,
  deliver: async (envelope) => {
    const response = await fetch("/sync", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(envelope),
    })
    if (!response.ok) throw new Error(`sync failed with ${response.status}`)
  },
})

On a Node server, the ingest is one call. It enqueues an internal message keyed on transmit:<effectId>, so a replayed envelope applies exactly once:

import { receiveTransmitEnvelope } from "solid-objects"

async function handleSyncRoute(request) {
  const sender = await authenticate(request)
  if (!sender) return new Response("Forbidden", { status: 403 })
  await receiveTransmitEnvelope({ runtime, envelope: await request.json() })
  return Response.json({})
}

But the receiving side does not have to be Node. The Ruby gem speaks the same wire contract, pinned by a golden fixture file committed to both repositories and tested on both sides. Its Rails engine already mounts POST /solid_objects/transmit, behind a policy that denies by default, so a Rails backend needs only to say who may deliver:

SolidObjects.configure do |configuration|
  configuration.authorize_transmission = lambda do |envelope:, authorization_context:|
    ActiveSupport::SecurityUtils.secure_compare(
      authorization_context.request.headers["Authorization"].to_s,
      "Bearer #{Rails.application.credentials.transmit_token}"
    )
  end
end

Point the browser's deliver callback at that route and you have an offline-first frontend draining into a plain Rails backend, one contract, both directions: Rails actors can transmit.increment(amount:) outward the same way. I have wanted this pairing since I first read about local-first software: durable actors in the tab and on the server, with a reconciliation path that survives a subway tunnel.

What it is not

Every claim above has a boundary, and you should know them before you spend an evening on this.

The project site keeps a longer version of this list right next to the things it does claim. Honestly, the not-claimed half is the part that makes me trust the rest.

Go break it

The model deserves to run everywhere. Cloudflare proved it at the platform altitude. celld proved it at the fleet altitude. Solid Objects is my argument that the most useful altitude is the lowest one: a library, your database, and now your browser tab.

Everything is MIT: solid-objects-js, solid-objects-ruby, and the docs, benchmarks, and correctness contract at solidobjects.dev. The quickstart asserts its claims and exits non-zero when one fails. The homepage runs the browser runtime live. If you find the case where a claim does not hold, I will name you in the fix.