Open Source Durable Objects for JavaScript, in the SQL database you already run. No daemon, no broker, and no new datastore.
Build addressable TypeScript objects with serialized calls and durable state on SQLite, PostgreSQL, or MySQL. You don't need Cloudflare for this.
Concurrent calls for one identity cannot overwrite each other. Calls for different identities can run at the same time.
Define ordinary TypeScript classes and run them in ordinary Node.js processes. Solid Objects keeps the state, the queued operations, the retries, the reminders, the effects, and the realtime invalidations in the database the application already operates.
The same runtime also runs inside a browser worker on SQLite WASM, with durable actor state in the origin's private file system, and its offline writes can replay onto a Node or Rails backend over one shared wire contract. See Solid Objects in the browser.
Not a replacement for SQL transactions: when one row update inside one transaction solves the problem, use that and install nothing. Solid Objects earns its cost when the critical section outlives the transaction: a hold that expires in ten minutes, work that must survive a restart, or a fan-in that spans many jobs. See Why not just use transactions?.
- The programming model
- Why not just use transactions?
- Is it worth installing here?
- Run it now with SQLite
- What Solid Objects is for
- Measured behavior
- Running in a deployed application
- How it works
- Delivery boundaries
- Realtime committed state
- Solid Objects in the browser
- Comparison
- Requirements and supported systems
- Operations
- Design provenance
- Documentation
- Status
- License
A ticket sale for one event, with 100 seats and a hold that expires:
import { Actor, broadcastValue, configure } 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> = {}
override observables(): Record<string, unknown> {
return { remaining: broadcastValue(this.remaining) }
}
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 = configure({
database: sqlite({ path: "tickets.sqlite3" }),
authorizeMessage: () => true,
authorizeQuery: () => true,
})
await runtime.install()
try {
const sale = TicketSale.ref("event-42")
const buyers = ["ada", "grace", "alan"]
await Promise.all(buyers.map((buyer) => sale.reserve({ buyer })))
} finally {
await runtime.close()
}Every reserve enters the durable mailbox for event-42. They execute in order
and commit one state transition at a time, even when different requests or
Node.js processes submit them concurrently, so the guard on remaining cannot
oversell.
That example wants three things from the same number. It must never go below zero. It must give the seat back if the buyer does not pay within ten minutes. It must show the current count to everyone watching the page.
The first is one UPDATE statement. The second is an expiresAt column plus a
sweeper. The third is a push on every code path that changes the number. The
combination is what costs, not any one of them. Here the guard, the ten-minute
alarm, and the published count are one class, and they commit together.
install() prepares the database and starts nothing. The example above finishes
because the caller's own path executes each call. A process serves background
work only after runtime.run(signal) starts its roles, so a process that
installs and then waits never claims a ready message. Nothing is lost while no
process runs. The message stays ready until one does.
const controller = new AbortController()
process.on("SIGTERM", () => controller.abort())
await runtime.run(controller.signal)Often you should. If the whole job is read a row, decide, write it back, and
answer the request, then a transaction with SELECT ... FOR UPDATE does that
and you need nothing else installed. In the browser, navigator.locks is the
same answer. Reach for those first.
The argument for an actor is scope, not discipline. A lock is scoped to one
transaction, on one connection, in one process. The ticket sale above leaves
that scope on one line: the hold expires in ten minutes, and no transaction
stays open for ten minutes. A setTimeout does not cover it either, because it
dies with the process.
Any column named expiresAt, scheduledAt, or nextRunAt is evidence that
the critical section already outlived the lock that was supposed to cover it.
What follows such a column is a sweeper that looks for due rows, and then a
race between that sweeper and the next writer of the same row. The column, the
sweeper, and the race are what a Solid Objects actor replaces.
Three cases a lock cannot reach:
- work that fires at a future moment, when no transaction of yours is open;
- work that must survive a process restart, which rules out an in-process timer; and
- a fan-in whose critical section spans many jobs over minutes, such as an import that counts its own chunks as each one finishes.
If it all happens inside one request, use a lock. If something has to happen later, or has to survive a restart, that is when this is worth installing.
Worth it when several requests, jobs, or processes act on the same cart, room, device, event, or session, and each next action needs the last committed state. Worth it when that same thing also owns work that fires later, or a number a live page must show: per-document reminders and realtime projections of committed state are the same argument.
Not worth it for a plain counter, a single-row update inside one transaction, a stateless job, bulk ingestion or a data-parallel pipeline, CPU-heavy work, a large JSON document that belongs in normalized rows, globally placed edge state, or a rate-limit counter that every request touches. One hot identity is serialized on purpose, so making everything one identity makes a queue. Split an identity only when the domain can tolerate independent ordering and transactions.
High-QPS reads and hot identities are where this runtime stops being the right tool on its own. Solid Objects Pro is a commercial performance layer for the family that adds grouped commits, which coalesce concurrent writes into fewer database commits; optional ephemeral operations, which take loss-tolerant calls out of the durable journal; and materialized projections, which build read models after commit so reads stop competing with mailbox work. It ships for the Rails gem today, and the Node build is in development.
What Solid Objects is for has the pattern table, and Choosing Solid Objects is the longer guide.
The published package includes a quickstart that needs no checkout, database server, container, or configuration:
npm exec --yes --package=solid-objects@latest -- solid-objects quickstartIt states its plan, prints the Counter class it runs, and asks before doing
anything. It asserts rather than printing a plausible result, and exits
non-zero when a check fails. Add --yes to skip the question, or --json for
a machine-readable summary.
Use Solid Objects when more than one request, job, or process can act on the same logical thing. The next action must then use the latest committed state of that thing. These are the stateful coordination patterns for which people often reach for Durable Objects:
| Pattern | One identity per | What the object coordinates |
|---|---|---|
| Multiplayer, presence, or collaboration | Room, session, or document | Joins, moves, and edits commit in order; subscribers refresh from committed state |
| Reservations and expiring holds | Show, resource, or stock item | Availability checks and holds cannot interleave; a durable reminder can release an old hold |
| Checkout and account workflows | Cart, order, account, device | The current step, retries, and effect results return to the same ordered mailbox |
| Quotas and expiring limits | API key, account, or device | Low-rate quota checks and decrements are serialized; a reminder can refill the bucket |
| Stateful agent sessions | Agent session | Messages and tool results apply in order and pending work survives a worker exit |
The common shape is one durable coordination boundary with an application defined identity. Work for that identity is serialized, while unrelated rooms, carts, accounts, or sessions can progress concurrently. Any very hot identity is a poor fit, because it becomes an intentional bottleneck.
Two of those rows have a limit. A quota fits when one identity checks it a few times per minute, because each check is one durable ordered message with a retained history row. A limiter that every request to that identity touches does not fit here. That high-QPS shape is what Solid Objects Pro targets.
A workflow fits when one entity owns the mutable state and its mailbox holds the step order. A durable execution engine that replays named steps from a step log is a different tool.
If one ordinary row transaction solves the problem, prefer that. See Choosing Solid Objects for the longer guide.
One developer machine, not a capacity promise: Apple M5, Node.js 24.18.0, 250 operations at client concurrency 16, on August 22, 2026.
| Measurement | Result |
|---|---|
| Committed operations per second, one hot identity, SQLite | 286 to 323 ops/s |
| The same identity across four processes, SQLite | 507 to 519 ops/s |
| Idle wake-up to committed result, one process | 2.66 ms p50 |
| Idle wake-up to committed result, two processes, polling only | 1,006 ms p50 |
Calls to one identity are serialized on purpose, so per-call latency includes the wait behind the other fifteen callers. That last row is the tradeoff to know before deploying: use PostgreSQL notifications or the optional Redis Pub/Sub when separate processes need low-latency delivery. The same databases in Docker Desktop reached 1.8x to 4.9x less throughput.
PostgreSQL and MySQL numbers, idle CPU, sources of bias, and the full matrix are in Benchmarks. Measure your own deployment shape before planning capacity.
Shuffle Up and Play is a deployed reference
application. Two players create a table, load decks, and move cards. Realtime
updates reach both browsers. Its
source uses Node 24,
TypeScript, SQLite, node:http, and ws. Each table code addresses one
GameRoom actor that owns both seats, so mutations share one durable mailbox
while each player receives a separately authorized projection.
The application and its tests exercise more than a counter-shaped happy path:
| Production concern | Verifiable application evidence |
|---|---|
| Concurrent mutations | One GameRoom owns a table. Mailbox tests submit concurrent life, draw, and shuffle operations and assert the final committed state. |
| Controlled restarts | Restart tests close and reopen the runtime against the same SQLite file, then assert recovery of committed state, an accepted asynchronous operation, an unfinished effect, and a scheduled reminder. |
| Persistent deployment | The runtime uses SQLite; the container runs as an unprivileged user, and Kamal mounts a persistent volume. |
| Private realtime state | Subscription policy and per-seat projection run on the server. HTTP and WebSocket tests assert that opponent card identities are absent from player payloads and shared invalidation envelopes. |
| External work | Deck imports run as durable effects with success and failure callbacks. Tests cover both outcomes and prevent a superseded callback from replacing a newer deck result. |
| Transactional staged work | A room operation stages an actor-to-actor log message and a database commit action. Tests cover rollback of staged messages and the metrics write. |
| Time and schema changes | The actor defines versioned state migrations and a durable reminder. Tests load stored version-one state and run the reminder scheduler. |
| Operations and CI | The operations tests exercise doctor, process, retention, and reconciliation APIs; server suites cover the dashboard, rate limits, and shutdown. The current main CI run passed typechecking, 171 tests, the build, the doctor, and a Docker image build. |
Scope: the checked-in deployment configuration runs one Node process with SQLite on one Docker host. It shows a real deployed workload. It does not show a measured traffic level or every supported topology. Its deck-import effect reads an external API. An effect that writes to an external system still needs a stable idempotency key, because delivery is at least once. The restart tests close the runtime cleanly. The library verifies abrupt termination, PostgreSQL, MySQL, and multi-process lease fencing separately in its test matrix, failure-recovery demonstration, and correctness contract. Compare those guarantees and limits with your own workload.
An actor is addressed by its class and an application-chosen ID. Fields are durable state, methods are operations, and getters are queries. One operation is one turn: the runtime claims the actor under a fenced lease, runs your JavaScript outside the transaction, then commits state, results, staged effects, reminders, and realtime invalidations together. A failure retries with backoff and dead-letters at the limit.
The fence includes the activation owner, token, generation, expiration, and claimed message, so a worker that finishes its JavaScript after losing the lease cannot commit. The database is the source of truth. Redis is optional acceleration, and polling remains the recovery path.
examples/failure-recovery/demo.ts kills a
worker mid-turn and shows the survivor finish it.
Architecture and
Operations cover the turn lifecycle, polling backoff, and
wake-up adapters.
- Operations are ordered per identity and execute at least once.
- Fencing protects the database commit. It cannot undo an HTTP request, email, payment, or file write, so external systems need the stable effect ID or another durable idempotency key.
- Different identities run concurrently; one hot identity cannot.
- Cross-object transactions are not provided.
- Realtime sessions are process-local, so a multi-process application must bridge committed broadcast events to the processes holding live connections.
Correctness and delivery semantics has the complete
contract, including stateVersion compatibility and the database write guard,
and Errors and recovery covers failure handling.
For a like count or a dashboard number, write the row and then send on your own socket. That is less code than this library and it works.
It gets harder when several people write to the same record at once. Each request builds its payload in its own process and sends it. The lock decided who wrote first, but it has no say over which of the two sends arrives last, so a viewer can be left looking at the older number. The second gap is that the send is not part of the write: if the process dies after the database commits and before the send goes out, the tab keeps a wrong number and nothing corrects it.
An observable is the alternative. The change and its publication commit together, so no crash can leave one without the other. A worker delivers the publication afterwards, claiming rows in actor revision order, and subscribers reject a duplicate or stale revision. Delivery is still at least once, so the guarantee is that a subscriber cannot end up on an older value, not that a value is sent exactly once.
Actors opt into browser-visible dependencies. In 0.13, an unwrapped
observable triggers invalidation without storing or sending its value. Use
broadcastValue() only for a scalar that every authorized subscriber may see:
import { Actor, broadcastValue } from "solid-objects"
class Room extends Actor {
static override readonly actorType = "Room"
version = 0
privateHands: Record<string, string[]> = {}
override observables(): Record<string, unknown> {
return {
version: broadcastValue(this.version),
hands: this.privateHands,
}
}
}version crosses the shared invalidation channel. hands contributes only its
name when its real value changes. A reauthorized component endpoint can then
render subscriber-specific state without a manual revision counter.
The browser package handles replay, reconnection, incarnation/revision fences, personalized payloads, and framework-neutral component refresh. Applications provide authentication, WebSocket transport, and rendering. See the browser protocol and authorization guide.
The full runtime runs inside a browser module worker. Actors look exactly like they do in Node; the database is SQLite WASM, and persistent storage lives in the origin's private file system (OPFS), so actor state survives page reloads.
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. sharedSqliteWasm elects one database
holder per origin through the Web Locks API, carries the other tabs' SQL to it
over a BroadcastChannel, and fails over onto the same durable state when the
holder's tab dies. Use sqliteWasm for a single dedicated worker.
Two companions complete the local-first story: solid-objects/browser/tab-host
runs one runtime for all tabs when the application prefers request-level
routing, and solid-objects/transmit drains the transactional effects outbox
to a server with at-least-once delivery, per-actor order, and an idempotent
server ingest, so offline writes reconcile when the network returns.
The browser runtime does not require a Node server behind it. The transmit wire
contract is shared with the Ruby gem
(solid-objects-ruby):
SolidObjects::Transmission.receive accepts the same envelopes as the Node
ingest receiveTransmitEnvelope, dedups on the same transmit:<effectId> key,
and both repositories pin the contract with one shared fixture file. A browser
front end therefore replays its offline writes directly onto Ruby server
actors, with no Node service in between:
class TransmitController < ApplicationController
def create
head :forbidden and return unless authenticated_device?
SolidObjects::Transmission.receive(JSON.parse(request.body.read))
head :ok
end
endThe Ruby side of the family shipped in solid_objects 0.14.0, released the
same day as this package's 0.14.0
(solid-objects-ruby#49).
The wire shapes are documented in the browser protocol, the API in the public API reference, and the platform boundaries in supported versions.
A SQL transaction or row lock serializes selected rows for one transaction and needs nothing installed. A job queue retries a job but leaves ordering to queue configuration. Solid Objects serializes by object ID, keeps state in the database you already run, and adds a library rather than a service. Cloudflare Durable Objects, Rivet, DBOS, and Restate each add a managed runtime or server.
celld and Solid Objects both self-host the Durable Objects model, and the difference is what you run. celld runs a daemon that embeds V8 and executes Wrangler bundles, gives each object its own SQLite database, replicates that database to an object-storage bucket you own, and moves ownership between nodes through compare-and-swap on that bucket. Solid Objects runs plain TypeScript classes inside your Node processes, adds no daemon, and keeps object state in the SQL database the application already operates. Choose celld to run Workers-format code across a fleet with bucket-based placement. Choose Solid Objects to keep one database, no extra process, and an ordinary Node deployment.
docs/comparisons.md has the full table across eight systems, with primary sources for every dimension: realtime projections, edge placement, cross-identity transactions, and operational data access.
Node.js 24.4.0 or newer, TypeScript 5.9 or newer, and SQLite through
node:sqlite, PostgreSQL 14 or newer, or MySQL 8.0 or newer on InnoDB.
pg, mysql2, ioredis, and @sqlite.org/sqlite-wasm are optional peer
dependencies, installed only for the adapters you use.
Supported versions records the exact CI matrix, the browser requirements for OPFS and the Web Locks API, and the boundaries.
runtime.run(signal) supervises the actor, effect, reminder, broadcast,
retention, and stale-process recovery roles. An optional solid-objects/web
export mounts a database-backed operator dashboard behind a deny-by-default
administration policy, with session-backed CSRF protection and Fetch or
Node/Connect mounting. Administration is also available through the JSON CLI
and typed runtime managers.
The dashboard has an explicitly public read-only mode. Use it only for synthetic demo data: it exposes stored arguments, results, errors, identifiers, and operational metadata. See Operations, the dashboard guide, and Configuration.
Solid Objects JS is a Node.js and TypeScript implementation. The Ruby
solid_objects design informed
it. It began at the 0.12 capability generation, because the first
implementation targeted the Ruby 0.12 contract. That number does not represent
twelve earlier JavaScript release generations.
The TypeScript implementation is not a source translation. It redesigned the
API around inferred TypeScript references, Node runtime supervision,
node:sqlite/pg/mysql2 adapters, transport-neutral realtime sessions, a
framework-neutral component registry, and browser-safe package exports. The
parity ledger records capability relationships and deliberate
runtime differences.
The Ruby project first appeared publicly on August 6, 2026, and this TypeScript
repository on August 13, 2026. Both remain early releases. The
mtg-playmat application uses the
Ruby actor and realtime design.
Shuffle Up and Play uses the
TypeScript package in the deployed Node and SQLite topology above.
- Getting the architecture right
- Correctness and delivery semantics
- Choosing Solid Objects
- Benchmarks and methodology
- Supported versions and test matrix
- Test suite
- CI workflow
- Public API
- State and lifecycle
- Operations, retention, and reconciliation
- Configuration
- Authorization
- Browser protocol
- Operator dashboard
- Errors and recovery
- Design parity
- Changelog
- Releases
- Contributing
- Security policy
Early release: the correctness core has automated coverage. That coverage includes the supported databases, the Chromium browser client, the browser runtime, process recovery, and the packaged artifacts. The TypeScript implementation is still new. There is one deployed first-party reference application. There is no measured scale and no third-party production use yet. Read the delivery boundaries before you use it for important data.
Solid Objects is released under the MIT License.