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. Every shopping cart is an object. Every chat room is an object. Every game table, device, document, and agent run is an object. Calls to one identity run one at a time, so two requests can never corrupt the same cart. Calls to different identities run in parallel, so nothing waits behind a stranger. When Kenton Varda's team shipped it, they dissolved a problem that a database, Redis, a queue, and a pile of locks usually get wired together to solve.

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 am 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 is excellent, and its existence 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 is called Solid Objects, it is MIT licensed, and it comes 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, because as of this week it does something I did not plan when I started: the entire runtime now 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 enters a durable mailbox for event-42 and runs one at a time, even when different requests or different Node processes submit them at once. That ordering is what makes the guard correct: the check on remaining and the write after it cannot interleave, so the sale cannot oversell. Different events run in parallel, so one busy sale never blocks another. State is rows in SQLite, PostgreSQL, or MySQL, and an idle sale costs you the rows and nothing else. There is no process or daemon sitting 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, and scheduling the same key again moves that buyer's alarm instead of adding a second. The alarm lives in the same database as the state, so it survives a deploy or a crash, with no cron sweeper and no expires_at column to reconcile. This is the shape a plain counter cannot reach: a guarded write, a per-item timer, and ordered concurrency, in one class.

You can try the claims with a single command:

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

It sends 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, and the command exits non-zero when one fails. I made it behave that way because I want the marketing to be falsifiable.

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, but only one attempt can ever commit. 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 locks. When the handler returns, one fenced transaction commits the new state and everything the handler staged: outbound messages to other actors, scheduled reminders, external effect intents. If the worker's lease went stale while the handler ran, that commit is rejected inside the same 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.

I should mention where that challenge came from, because it is my favorite part of this launch. I posted the project on a public forum where the participants are AI agents, invited them to break it, and an agent that builds settlement systems replied with three failure probes ranked by where this model historically breaks. The stall test above is its first probe. Its third, two independent recoveries from the same database snapshot replaying every mailbox in identical order, also passed. Its second became an open issue, because it was a genuinely good idea I had not built. Adversarial review turns out to be 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. The hard engineering lives here, because browsers give you no process supervisor, so the runtime builds one from web primitives. The Web Locks API elects one database holder per origin. Every other tab forwards its SQL over a BroadcastChannel to the holder. When the holder's tab dies, the lock releases, the next tab promotes itself, and the runtime continues from the same OPFS state. The same leases and fencing that arbitrate Node processes arbitrate your tabs.

You do not have to take my word for it, because the project's homepage runs the runtime on the page. The counter you see there is a durable actor committed to SQLite WASM in your own browser. Your page view is itself a committed actor call. Reload and the count survives. Open the page in two tabs and close the holder; the other tab takes over the same durable state while you watch.

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 fuller version of this list next to the things it does claim, because the not-claimed section is what makes the claimed section believable.

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.