npm: solid-objects

Open Source Durable Objects for JS, using your existing SQL database

No daemons required. Use your existing infrastructure.

Pre-1.0 v0.14.0 · expect breaking changes In production in apps with 100,000+ users

MIT license · Node.js 24+ · SQLite / PostgreSQL 14+ / MySQL 8+ · SQLite WASM in the browser

Quickstart

An actor is just a class

counter.tstypescript
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.

Browser native

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.

  • 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.

  • Offline writes reconcile. solid-objects/transmit drains the transactional outbox to a server runtime with at-least-once delivery, per-actor order, and idempotent ingest when the network returns.

  • That server can be 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.

app.js — runs in every tabjavascript
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.

How it works

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.

Actor commit flow: a call becomes a durable message in the mailbox; a worker claims the actor under a fenced lease; the handler runs outside the transaction; it stages the result and consequences; one fenced transaction commits state and staged work together; a reject or throw rolls back; a stale lease holder is rejected by fencing; external effects run at least once outside the transaction and deduplicate by a stable effect id; then a post-commit wake-up takes the next turn. Actor commit flow, top to bottom: a call becomes a durable message in the mailbox; a worker claims the actor under a fenced lease; the handler runs outside the transaction; it stages the result and consequences; one fenced transaction commits state and staged work together; a reject or throw rolls back; a stale lease holder is rejected by fencing; external effects run at least once outside the transaction and deduplicate by a stable effect id; then a post-commit wake-up takes the next turn.
The commit path for one actor turn. State and staged work commit atomically; external effects are the deliberate exception.

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.

Compare

Managed platform, self-hosted fleet, or library

The model — one single-threaded object with its own durable state, addressed by name — 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.

One programming model at three altitudes
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.

Measured characteristics

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.

Measured on the 0.13 series. Latency starts before durable enqueue and ends when the committed result is available.
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.

What it is for

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.

Good fit
  • 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.
Reach for something else
  • 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.
Deployed reference application

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.

What the deployed application exercises. Every row is verified in its source and tests.
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.

Documentation

Read the contract

The claims above are backed by docs, a test suite, and a runnable multi-process crash-and-fencing demonstration.

Correctness

correctness.md — delivery and commit semantics.

Architecture

architecture.md — mailbox, leases, fencing, adapters.

Parity

parity.md — Native, Partial, and Planned ledger.

API

api.md — public exports and runtime managers.

Lifecycle

state-and-lifecycle.md — state, migrations, snapshots.

Errors

errors-and-recovery.md — the error contract and retries.

Crash / fencing demo

demo.ts — two processes, SIGKILL, lease takeover, correct final state.

Package

npm: solid-objects — the published artifact.

Why this exists

Author and project history

Author
Lucas Carlson has authored over 20 open source projects with over 10 million downloads and thousands of stars across GitHub projects.
Ruby and TypeScript
The TypeScript package ports the programming model of the Ruby solid_objects gem. The two runtimes share no database schema or code, but since 0.14.0 they share one wire contract: the transmit family, pinned by golden fixtures committed to both repositories. The package is versioned to track parity with the gem, which is why it begins at 0.12.
Production
The Ruby implementation is used in a mature Rails codebase with over 100,000 users.
FAQ

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.