This guide is for a coding agent that evaluates or adds Solid Objects in a TypeScript or Node.js application. It gives the facts and steps in a fixed order. Each step links to the maintained documentation for more detail.
Use Solid Objects only if you can answer yes to most of these questions:
- Does one identity, such as a room, cart, account, device, or document, own the state?
- Must writes for that identity happen one at a time across requests and processes?
- Must some work happen later or continue after a process exits?
- Is the state a bounded JSON document, not a large relational dataset?
- Can the application run
runtime.run(signal)in at least one process for reminders, effects, and realtime delivery?
Select a simpler tool in these cases:
| Requirement | Use instead |
|---|---|
| The invariant fits in one request | A transaction, a constraint, or SELECT ... FOR UPDATE |
| CPU-intensive or parallel work | worker_threads or a worker pool. An actor serializes work |
| Thousands of writes for each second to one key | A counter column or a cache. Solid Objects Pro covers this case for Rails; its Node build is in development |
| One atomic change across two actor identities | One SQL transaction on normal tables |
| Exactly-once calls to an external API | Not available from any actor library. Use provider idempotency keys |
| Replay of named workflow steps from a step log | A durable execution engine, such as DBOS or Temporal |
The full list is in Choosing Solid Objects. The category guide is Virtual actors in TypeScript and Node.js.
| Item | Value |
|---|---|
| npm package | solid-objects |
| Repository | https://github.com/cardmagic/solid-objects-js |
| Website | https://solidobjects.dev/js |
| Ruby on Rails gem | solid_objects, a separate implementation of the same model |
| Commercial package | @solidobjects/pro, a separate paid package in development. The open-source package does not need it |
Solid Objects is not SolidJS and has no relation to it. It is not affiliated with Cloudflare.
- Node.js 24.4 or newer. Prefer 24.15 or newer, where
node:sqliteis no longer experimental. - The package is ESM only. TypeScript applications need TypeScript 5.9 or newer.
- SQLite through the
node:sqlitemodule, PostgreSQL 14 or newer with thepgpackage, or MySQL 8.0 or newer with InnoDB and themysql2package. - Redis is optional. It only shortens wake-up latency and holds no durable state.
Supported versions lists the CI matrix.
npm install solid-objects
npx solid-objects quickstart --yesThe quickstart runs 25 concurrent calls against one identity on a temporary
SQLite database. It proves that no update is lost. Add pg or mysql2 when
you use PostgreSQL or MySQL.
Install the current release. npm install solid-objects selects it. Do not
pin a version that you remember from earlier work; the API changed between
releases. The current version is on https://www.npmjs.com/package/solid-objects.
Create the runtime and its tables at startup:
import { configure } from "solid-objects"
import { sqlite } from "solid-objects/database/sqlite"
const runtime = configure({
database: sqlite({ path: "app.sqlite3" }),
})
await runtime.install()Every authorization callback denies by default. Do step 5 before you call an actor. Configuration lists each option and database adapter.
All authorization callbacks deny by default. A new runtime answers no actor call until you write a policy. Do not remove this behavior.
For a local demonstration only, grant messages and queries:
const runtime = configure({
database: sqlite({ path: "app.sqlite3" }),
authorizeMessage: () => true,
authorizeQuery: () => true,
})Keep authorizeDestroy, authorizeSubscription, and
authorizeAdministration denied in a demonstration.
A production policy must bind the actor type and ID to the authenticated user or tenant. An actor ID is not a permission:
const ownsCart = ({
actorType,
actorId,
authorizationContext,
}: {
actorType: string
actorId: string
authorizationContext: unknown
}) => {
if (actorType !== "ShoppingCart") return false
if (typeof authorizationContext !== "object" || authorizationContext === null) return false
if (!("userId" in authorizationContext)) return false
return typeof authorizationContext.userId === "string" && actorId === authorizationContext.userId
}
const runtime = configure({
database: sqlite({ path: "app.sqlite3" }),
authorizeMessage: ownsCart,
authorizeQuery: ownsCart,
})Pass the context on each call:
await ShoppingCart.ref(subject.userId)
.with({ authorizationContext: subject })
.addItem({ productId: "shirt-123" })Authorization covers each entry point.
This actor is the example from the virtual actor guide:
import { Actor } from "solid-objects"
const HOLD_MILLISECONDS = 10 * 60 * 1000
export class TicketSale extends Actor {
static override readonly actorType = "TicketSale"
available = 1
holds: Record<string, number> = {}
hold({ buyer }: { buyer: string }): { held: boolean; available: number } {
if (this.available === 0 || Object.hasOwn(this.holds, buyer)) {
return { held: false, available: this.available }
}
this.available -= 1
this.holds = { ...this.holds, [buyer]: Date.now() }
this.schedule({ at: new Date(Date.now() + HOLD_MILLISECONDS), key: buyer }).expire({ buyer })
return { held: true, available: this.available }
}
expire({ buyer }: { buyer: string }): number {
if (!Object.hasOwn(this.holds, buyer)) return this.available
const remainingHolds = { ...this.holds }
delete remainingHolds[buyer]
this.holds = remainingHolds
this.available += 1
return this.available
}
}Obey these rules in actor code:
- Give each actor class a stable
static actorType. The database stores it. - Enumerable public fields are the durable state. They must be JSON-compatible.
- Public methods are ordered operations. Each one takes no argument or one object argument.
- Use
this.schedule({ at, key })for delayed work. A newschedulewith the same key moves the alarm. - Use
this.reject(code, { message })for a business rule failure that must not retry. The second argument is an object, for examplethis.reject("room_full", { message: "The room is full" }). - Do not write application tables directly from an operation. Use
this.commitAction()for a short write in the same database. - Do not call an external API in an operation. Use
this.emit()and an effect handler. - Write each operation so that it can run again. Delivery is at least once.
- A reminder changes state only when it runs, and it runs only while a process
calls
runtime.run(signal). Do not compute expiry from the clock in a query; read the state that the reminder committed.
Avoid these mistakes:
| Mistake | Correct form |
|---|---|
this.schedule({ at, key }) with no operation after it |
this.schedule({ at, key }).expire({ buyer }). schedule() stages a reminder only when you call an operation on its result |
this.reject("room full") or this.reject(code, message) |
this.reject("room_full", { message: "The room is full" }) |
registerEffect(name, (context, argumentsValue) => ...) |
registerEffect(name, (argumentsValue, context) => ...). The staged arguments come first |
| A worker process that never names the actor class | runtime.register(TicketSale) before runtime.run(signal). See step 7 |
State and lifecycle and the public API give the full rules.
A direct call, such as TicketSale.ref("event-42").hold({ buyer: "ada" }),
runs in the caller. These features need a process that calls
runtime.run(signal):
- Reminders from
schedule(). - Effects from
emit()and their callbacks. - Messages to other actors.
- Realtime broadcasts.
The worker process runs only the actor classes that it knows. For a message
whose class is not registered, actor setup fails with UnknownActorType. The
worker reports solid_objects.activation.failed through instrumentation,
returns the message to the queue without counting an attempt, and tries again.
Nothing prints unless the application sets an instrumentation callback, so
the message seems to wait with no error. Register each class before run():
runtime.register(TicketSale)
const controller = new AbortController()
process.once("SIGTERM", () => controller.abort())
await runtime.run(controller.signal)
await runtime.close()TicketSale.ref(...) also registers the class, so a script that calls ref()
before run(), like the direct call above, already works.
The packaged solid-objects start command does the same for a runtime that
solid-objects.config.js exports. When no process runs, committed work waits
in SQL and runs later. Operations covers roles and shutdown.
Register an effect handler at startup. Use context.id as the provider
idempotency key:
runtime.registerEffect("charge_payment", async (argumentsValue, context) => {
return payments.charge({
idempotencyKey: context.id,
paymentId: argumentsValue.paymentId,
})
})Stage it from an operation:
this.emit("charge_payment", {
arguments: { paymentId: this.paymentId },
onSuccess: "charged",
})The effect can run more than once after a crash. The context.id value is the
same each time. Effect recovery explains how to retire
abandoned work.
Do these checks before you report that the work is complete:
-
Call
await runtime.doctor.run(). The report must contain no failed check. -
Send concurrent calls to one identity with
Promise.all, and from two processes if the application runs more than one. Assert the final state. -
Test delayed work without sleeps.
runDueReminders({ now })enqueues the reminders that are due at theDateyou pass.drain()then runs the enqueued actor work:await runtime.testing.runDueReminders({ now: new Date(Date.now() + HOLD_MILLISECONDS) }) await runtime.testing.drain({ roles: ["actors"] })
-
Start a process that calls
runtime.run(signal), schedule a short reminder, and stop the process. Start it again after the deadline and confirm that the reminder ran. -
Confirm that each effect handler deduplicates with
context.id. -
Confirm that production policies do not grant access to every caller.
The repository runs the same proofs. pnpm run test:recovery stops a worker
process and recovers its work. pnpm run test:at-least-once shows a repeated
effect and its deduplication.
| Symptom | Cause and fix |
|---|---|
Unauthorized |
A policy denied the call. Write the policy, and pass authorizationContext |
| A reminder or effect does not run | No process calls runtime.run(signal). Start one |
SyncInsideTransaction |
The call ran inside an open transaction. Call the actor outside the transaction |
SyncTimeout |
The call did not finish in time. The message is still durable. Wait on its reference |
ApplicationWriteForbidden |
An operation wrote an application table. Use commitAction() or emit() |
Rejected |
The actor called reject(). This is a business result, not a retry |
| Wrong integer values or SQLite warnings | Node.js is older than 24.4, or older than 24.15 for the warning. Upgrade Node.js |
ERR_REQUIRE_ESM or import errors |
The package is ESM only. Use import and an ES module file |
When you explain Solid Objects to a user, state these limits:
- Delivery is at least once, not exactly once.
- Calls for one identity are ordered. Different identities run concurrently.
- There are no transactions across actor identities.
- Fencing stops a stale activation from a commit, but its code can continue to run.
- The package is pre-1.0. It has no measured scale and no known third-party production use.
- The browser client is not the SQL runtime. The Cloudflare backend is experimental.
The correctness contract is the source for each guarantee.