Blog ·

Introducing Solid Objects

Open source Durable Objects on the Postgres, MySQL, or SQLite you already run.

Durable Objects is a good model. One object, one identity, one call at a time, and state that outlives the request. Getting that model has meant taking on something else.

Solid Objects gives you the same model as a library. Each object has an identity, durable state, and an ordered mailbox, and all of it lives in the SQLite, PostgreSQL, or MySQL database you already run. No daemon, no broker, no account.

I shipped the first version two days before Shopify published the same architecture for inventory reservations. Same conclusion, reached separately.

Why I built this

An app I maintain ran a cron job every five minutes. Each run loaded every active account in the database to check whether any were due to shut down. One week of that came to 2,014 runs and 37 minutes of queue time. It turned up 8 whole accounts.

Elsewhere in the same app, a scheduled launch lived as one key per target in a key value store. A job scanned all of them every half hour, recovered the target by parsing the key with a regular expression, and still ran up to thirty minutes late. The controllers enqueued a second, delayed copy of that job to cover the gap.

Nobody was careless. Each piece had been added to cover for the one before it.

I took that for a local failing until I read Brian Chesky answering a thread titled "What happens when a host cancels with Airbnb?" It hadn't been a cancellation. "The host did not cancel, we double-booked." A company whose entire product is reservations had shipped the bug I was building scaffolding against.

That left two options and I disliked both. Move onto Cloudflare Durable Objects, which is the right model, and hand over the state, the bill, and the ability to leave. Or keep the sweeps.

The third option was already in front of me. The deadline lived in the database. I moved the schedule there too, so each entity arms one reminder when it's own setting changes and wakes itself at the right time. I ran the old sweep beside it until the two agreed in production, then deleted the cron entry.

And then Shopify published the same move: inventory reservations out of Redis and into MySQL, one row per unit, claimed with SKIP LOCKED.

What it replaces

Doing this by hand starts with a row lock, then a Redis lock once the row lock cannot span two requests. The expiry needs a delayed job, which needs an expires_at column and a sweeper once you find it dies with the process. Then retry code, and a broadcast that may disagree with the write it followed.

Six pieces that all have to agree. Solid Objects replaces them with one object.

What works today

ticket-sale.ts
import { Actor } from "solid-objects"

class TicketSale extends Actor {
  static actorType = "TicketSale"
  remaining = 100

  reserve({ buyer }) {
    if (this.remaining === 0) return false
    this.remaining -= 1
    this.schedule({ at: "10 minutes", key: `release:${buyer}` })
    return true
  }

  release({ buyer }) {
    this.remaining += 1
  }
}

await TicketSale.ref("event-42").reserve({ buyer: "Ada" })

Two requests can call reserve at the same instant, from two processes. Both enter the mailbox for event-42, and the runtime commits one turn at a time, so the count never drops below zero. The ten-minute release is stored as a row, so it still fires after a deploy.

A two-player Magic table. Alice and Bob each hold a seat with a life total, a
              library count and cards laid out on their own battlefield.
One table, one actor. Every draw, tap and move from both seats enters the same mailbox and commits in order, so the two screens cannot disagree. Live at shuffleupandplay.com.

How a turn works

  call ──▶ mailbox ──▶ lease ──▶ turn ──▶ ONE TRANSACTION
           ordered     fenced                    │
           per id      per id                    ├──▶ state
                                                 ├──▶ reminders
                                                 ├──▶ effects (outbox)
                                                 └──▶ broadcasts

Each identity gets an ordered mailbox and a fenced lease. One process wins the lease and runs the turn, then state, reminders, effects, and broadcasts commit together. A worker that lost its lease can't commit. Redis is optional and only shortens wake-up latency.

What it costs

On a laptop with SQLite, a durable call from enqueue to committed completion runs about 2.6 ms at p50 inside one process. Across two processes with polling alone it waits about a second, which is why PostgreSQL notifications and an optional Redis wake-up exist.

The harness and the caveats are in docs/benchmarks.md. These are developer-laptop measurements. They don't predict application capacity.

Install

Terminal
# Node 24+
npm install solid-objects
npx solid-objects quickstart --yes

# Rails 7.1+
bundle add solid_objects
bin/rails solid_objects:install

No daemon, no broker, no account. The install adds tables to your database, the way Solid Queue and Solid Cache do. Views update through reactive ERB and Turbo Streams on Rails, or committed projections on Node, and a browser runtime on SQLite WASM replays offline writes back to a server.

What it is not

There's no edge placement and no routing between regions, and no transaction spanning two identities. Delivery is ordered and at least once, so an external effect has to be idempotent. Exactly once remains absent, despite its excellent branding. It is pre-1.0, which is not decorative punctuation.

It runs in production in one application with more than 100,000 users. There is a demo at shuffleupandplay.com and no third-party production use yet. If your whole invariant fits inside one request, use a transaction.

Next up

Reproducible benchmarks, more soak time on PostgreSQL and MySQL, then 1.0.

← All posts · RSS