Open Source Durable Objects for JS, using your existing SQL database
No daemons required. Use your existing infrastructure.
An actor is just a class
import { Actor } from "solid-objects"
class Counter extends Actor {
static override readonly actorType = "Counter"
count = 0
increment(): number {
this.count += 1
return this.count
}
}
// Five concurrent calls to one identity, serialized:
const counter = Counter.ref("global")
const results = await Promise.all([1, 2, 3, 4, 5].map(() => counter.increment()))
console.log(results.sort((a, b) => a - b)) // [ 1, 2, 3, 4, 5 ]
The full runnable file, with runtime setup, is /examples/counter.ts. SQLite needs no driver; it uses Node's built-in node:sqlite.
The whole runtime runs in the browser
Not a thin client: the full Solid Objects runtime runs inside a browser module worker. Actors look exactly like they do in Node. The database is SQLite WASM, durable state lives in the origin's private file system (OPFS), and it survives page reloads. Frontend apps get durable, serialized actors with no server round-trip.
Starting the runtime in a module worker…
This count is a durable actor in your browser. Reload the page: it survives. Open a second tab: both tabs share it. Close the holder tab: the other takes over.
-
✓
Same actors, same contract. One mailbox, one fenced lease, one commit. The runtime's leases and fencing arbitrate browser tabs exactly as they arbitrate Node processes.
-
✓
Durable in the tab. sharedSqliteWasm persists to OPFS, so committed actor state survives reload and browser restart.
-
✓
Multi-tab by construction. The Web Locks API elects one database holder per origin, other tabs send SQL over a BroadcastChannel, and when the holder's tab dies the next one fails over onto the same durable state. A Playwright test proves two tabs incrementing one durable counter through failover.
-
✓
The transmit outbox drains to Node or Rails. A Node runtime ingests transmit envelopes with receiveTransmitEnvelope, and the same wire contract is shared with the solid_objects Ruby gem, which ingests them at POST /solid_objects/transmit. Browser actors stage writes in the tab; either backend applies them idempotently. Golden fixtures pin the contract in both repositories.
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()
This code runs identically in every tab of the origin, straight from the README. Use sqliteWasm for a single dedicated worker, or solid-objects/browser/tab-host to run one leader runtime that every tab invokes by name.
import { Actor, configure, sharedSqliteWasm } from "https://esm.sh/solid-objects@0.14.0/browser/host"
No build step and no package manager: esm.sh rewrites the SQLite WASM dependency, so this one import works in any module worker. Pin the version, and allow https://esm.sh and 'wasm-unsafe-eval' in your page's CSP.
How a turn commits
One mailbox, one fenced lease, one commit. Your handler runs outside the database transaction; its result and everything it stages commit together, and a stale lease holder is rejected at commit time.
Delivery is ordered per identity and at least once. External effects run outside the transaction and can repeat, so make them idempotent and deduplicate by their stable effect id. The full contract is in correctness.md and architecture.md.
Managed platform, self-hosted fleet, or library
The model is one single-threaded object with its own durable state, addressed by name. It comes from Cloudflare's Durable Objects team. celld, from Deno Land, runs that model as a self-hosted fleet. Solid Objects runs it as a library inside the app you already deploy. Pick by where you want state to live and what you want to operate.
| Cloudflare Durable Objects | celld | Solid Objects | |
|---|---|---|---|
| What it is | A managed platform on Cloudflare's edge | A self-hosted daemon that runs Workers code on your VMs | A library inside your Node or Rails process |
| You operate | Nothing; Cloudflare places and runs objects | celld nodes plus an object-storage bucket you own | Only the app and SQL database you already run |
| State lives in | Per-object storage inside Cloudflare | One SQLite database per cell, replicated to your bucket | Tables in your SQLite, PostgreSQL, or MySQL |
| New infrastructure | None; state is bound to the platform | VMs and a bucket | None |
| Global edge placement | Yes | No; the regions you choose | No; the regions you choose |
| License | Managed service; the workerd runtime is open source | Open source, Apache-2.0 | Open source, MIT |
| Best when | You want zero operations and placement near users | You want the Workers API on infrastructure you control | You want durable actors in one app with no new moving parts |
All three share the same good idea. celld's authors call their implementation a “love letter” to the Durable Objects design; Solid Objects is the same admiration, aimed at the database you already have. This is an independent project, not affiliated with Cloudflare or Deno Land.
Numbers, and the harness that made them
Every operation is a committed database transaction, and every number below includes that commit. Laptop measurements show tradeoffs, not capacity promises.
| Characteristic | Measured | Setup |
|---|---|---|
| Committed turns per second, one hot identity | 487 turns/s | SQLite, four worker processes |
| Committed turn latency, one hot identity | p50 29 ms · p99 68 ms | SQLite, four worker processes |
| Wake from idle to committed completion | p50 2.7 ms | SQLite, one process, in-process wake-up |
| Idle polling cost | ~0.1% CPU per role process | SQLite, all four runtime roles at the one-second ceiling |
| Cost of an idle actor | Rows in your database | No resident process and no per-actor memory |
| Committed turns, one hot identity, PostgreSQL | 86 turns/s · p50 185 ms | PostgreSQL 18 in Docker Desktop, four worker processes |
How these were measured. August 2026 on an Apple M5 laptop (10 CPUs, 24 GiB) with Node.js 26.7: 25 warmup and 250 measured operations at concurrency 16, asynchronous handlers, loopback connections. Cold identities and fan-out across many identities are slower. The full tables, the harness, and the sources of bias are in benchmarks.md. Run it yourself with pnpm run benchmark -- --database sqlite.
Stateful features that keep re-assembling the same machinery
Reach for Solid Objects when many independently-addressed entities each need ordered state changes, durable work, recovery, and realtime updates, and you would otherwise wire that together from a database, Redis, a queue, and locks.
- Game tables, chat rooms, and live collaborative documents.
- Carts, orders, accounts, and ledgers with concurrent updates.
- Devices, sessions, and agent runs that need per-entity ordering.
- Local-first browser apps: the same actors on SQLite WASM in the tab, with a transactional outbox that transmits to a server runtime.
- Anything where one identity must serialize its own writes and survive a restart.
- One short SQL transaction already enforces the invariant.
- Bulk or data-parallel pipelines with no per-identity state.
- A single hot identity that must outrun one sequential mailbox.
- Atomic transactions spanning many identities, or edge placement near users.
Shuffle Up and Play: one deployed reference application
Shuffle Up and Play is a deployed two-player Magic: The Gathering table built on Solid Objects. Each table is one durable GameRoom actor: it serializes mutations, persists state, runs background work, and sends a different authorized projection to each player. The stack is small on purpose: Node 24, TypeScript, SQLite, node:http, and ws, with no web framework and no bundler. This is one deployed first-party reference application. There is no measured scale and no third-party production use yet.
Live application · Source · Tests · CI
| Production concern | How it is handled | Source |
|---|---|---|
| Concurrent writes | One GameRoom actor per table serializes mutations; 25 concurrent life changes converge to one deterministic result. | game-room.ts |
| Private realtime state | Each seat gets its own payload projection; the opponent's hand and library render as “Hidden card,” tested at the actor, WebSocket, and browser-client layers. | room-snapshot.ts |
| External work | Deck imports run as durable effects with success and failure callbacks; superseded results are ignored. | runtime.ts |
| Recovery | Committed state, an accepted message, an unfinished effect, and a scheduled reminder all survive a runtime restart. | restart.test.ts |
| Operations | Doctor checks, retention, reconciliation, dead-letter inspection, and an optional loopback dashboard. | doctor.ts |
Scope of this evidence. The public deployment is a single Node process backed by one on-disk SQLite database; it demonstrates a real deployed workload, not every topology. “Recovery” means state survives a graceful restart, verified by tests, not a specific traffic level. The player experience is a standalone application, not an interactive Solid Objects tutorial.
Read the contract
The claims above are backed by docs, a test suite, and a runnable multi-process crash-and-fencing demonstration.
correctness.md : delivery and commit semantics.
architecture.md : mailbox, leases, fencing, adapters.
parity.md : the Native, Partial, and Planned ledger.
api.md : public exports and runtime managers.
state-and-lifecycle.md : state, migrations, snapshots.
errors-and-recovery.md : the error contract and retries.
demo.ts: two processes, SIGKILL, lease takeover, correct final state.
npm: solid-objects: the published artifact.
Author and project history
Common questions
Can I use Solid Objects in the browser or a frontend app?
Yes, in two ways. The full runtime runs inside a browser module worker: the same actor classes on SQLite WASM, durable in OPFS across reloads, multi-tab safe through Web Locks and a BroadcastChannel, with offline writes reconciled to a server through solid-objects/transmit. And solid-objects/browser is a zero-Node-imports realtime client for subscribing to server-side actors. See the browser section.
Can I use the JS library in the frontend with a Rails backend?
Yes, that pairing is a designed use. Run the browser runtime in the frontend and the solid_objects Ruby gem in Rails. Since 0.14.0 both implementations share the transmit wire contract: a browser actor stages writes transactionally, drains them with at-least-once delivery and per-actor order, and Rails ingests each envelope idempotently at POST /solid_objects/transmit behind a deny-by-default authorization policy. Browser-to-Rails, Node-to-Rails, Rails-to-Node, and Rails-to-Rails all ride the same contract, pinned by golden fixtures in both repositories.
Can I run Durable Objects without Cloudflare?
Yes. Solid Objects is an open-source MIT library that runs the Durable Objects programming model inside your own Node.js application, with state in SQLite, PostgreSQL, or MySQL. celld, from Deno Land, is another option: a self-hosted daemon that runs Workers code on your own VMs with state replicated to an object-storage bucket.
How is Solid Objects different from celld?
celld is a self-hosted daemon: you operate celld nodes on VMs plus an object-storage bucket, and each cell is its own SQLite database replicated to that bucket. Solid Objects is a library: it runs inside the app you already deploy and stores state as tables in the SQL database you already run. Neither requires Cloudflare. Choose celld for the full Workers API on your own fleet; choose Solid Objects for durable actors in one app with no new moving parts. See the comparison table.
Is Solid Objects affiliated with Cloudflare?
No. It is an independent open-source project that ports the Durable Objects programming model to Node.js. It does not reproduce Cloudflare's edge placement, global routing, WebSocket hibernation, or storage APIs.
Does it guarantee exactly-once execution?
No. Delivery is ordered per identity and at least once. Operations and external effects can repeat, so make external effects idempotent and deduplicate by their stable effect id. Fencing ensures at most one valid lease holder can commit.
Do I need Redis or a message broker?
No. State, mailbox, leases, fencing, retries, timers, and outboxes live in SQLite, PostgreSQL, or MySQL. Redis is an optional wake-up latency layer; losing it increases polling latency without losing committed work.
Is it production ready?
It is an early pre-1.0 release (v0.14.0); expect breaking changes. There is one deployed first-party reference application. There is no measured scale and no third-party production use yet. Evaluate it with the runnable quickstart, the reference application, and the test suite.
Which Node.js and database versions are required?
Node.js 24.4 or newer and TypeScript 5.9 or newer. SQLite uses the built-in node:sqlite. PostgreSQL 14 or newer uses the optional pg driver; MySQL 8.0 or newer with InnoDB uses the optional mysql2 driver.