Skip to content
Storage / Durable Objects

Storage

Durable Objects

Run Cloudflare Durable Objects on OpenOrange, where each object is one Rivet Actor with its own SQLite database.

Status: Partial. Each object runs as one Rivet Actor with its own SQLite database. WebSocket hibernation is emulated, and a few APIs throw. See What works.

To move existing objects from Cloudflare, see Migrate from Cloudflare. For how Orange builds each part, see the implementation notes.

Configuration

Use the standard Wrangler binding and migration:

{
  "durable_objects": {
    "bindings": [{ "name": "COUNTER", "class_name": "Counter" }]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["Counter"] }
  ]
}

Export the class from the Worker entrypoint, and import the base class from cloudflare:workers:

import { DurableObject } from "cloudflare:workers";

export class Counter extends DurableObject {
  async increment() {
    const value = (this.ctx.storage.kv.get("count") ?? 0) + 1;
    this.ctx.storage.kv.put("count", value);
    return value;
  }
}

Orange reads the configuration like wrangler dev:

  • Each configured class needs one new_sqlite_classes or new_classes migration. Orange stores both kinds in SQLite.
  • More than one binding can name one class.
  • A migrated class with no binding is still registered. Reach it through ctx.exports or the exports import from cloudflare:workers.
  • A renamed class keeps its objects. A deleted class’s objects stay in storage.
  • A class from transferred_classes starts empty. Orange does not move the objects that the other Worker stored.
  • Other migration fields stop orange dev with an error.
  • A binding with script_name works when that Worker runs in the same orange dev session. See Multiple Workers.
  • A binding with environment or namespace_id loads as a stub that throws when you use it.
  • Migration tags do not run SQL. Create your tables in the constructor, for example with CREATE TABLE IF NOT EXISTS.

A plain class that does not extend DurableObject gets fetch() and alarms. RPC to a plain class fails, also with the js_rpc flag, because Orange does not read compatibility flags.

What works

APIStatusNotes
idFromName(), newUniqueId(), idFromString(), get(), getByName(), jurisdiction()SupportedLocation hints and jurisdictions are checked, but they do not change where an object runs.
Method RPC on a stubSupportedIncludes streams, RpcTarget, callbacks, and pipelined calls.
stub.fetch()SupportedRequest and response bodies stream.
stub.connect()Not supportedThrows. Service binding connect() works.
ctx.storage.sqlSupportedCursors are eager. See Storage.
ctx.storage.kv and the async storage APISupported
transactionSync() and transaction()Supported
AlarmsSupportedSee Alarms.
getCurrentBookmark(), waitForBookmark()SupportedBookmarks use the local workerd format.
getBookmarkForTime(), onNextSessionRestoreBookmark()Not supportedThrow DurableObjectStorage.<name> is not implemented.
blockConcurrencyWhile(), waitUntil(), abort()Supportedabort() restarts the complete Rivet Actor.
ctx.exportsSupported
Standard WebSocketsSupported
WebSocket hibernation APIPartialEmulated. See WebSocket hibernation.
ctx.facetsPartialThe part that the agents SDK uses. See Facets.
ctx.containerPartialRuns only Cloudflare Sandbox images. See Sandbox.
ctx.props, ctx.restore, ctx.configureReadReplication, ctx.primaryStub, ctx.versionNot supportedReading one throws a NotSupportedError.

How an object runs

Each object is one Rivet Actor. The JavaScript instance of your class lives inside that actor. The instance and the actor stop at different times:

An object with one accepted WebSocket. After 10 seconds with no event, Orange drops the instance, and the actor and the socket stay. A message builds a new instance. After the socket closes and 10 more idle seconds, the actor sleeps. The next request wakes it.

  • The instance is dropped after 10 seconds without an event while an accepted WebSocket is open. The actor keeps running, and the socket stays open. The next event builds a new instance.
  • The actor sleeps after 10 seconds without an event when no accepted WebSocket is open. The next request, RPC call, or alarm wakes it and builds a new instance.
  • Instance fields are lost in both cases. Keep state in ctx.storage.

These keep the instance running past the idle timeout:

  • A standard WebSocket.
  • A WebSocket that the object opened, while it is open.
  • A fetch() until its response headers arrive, also when nothing awaits it.
  • An open RPC session, or an RPC result that a caller still holds.
  • A waitUntil() or ctx.keepAwake() task.
  • A pending setTimeout() or setInterval() callback, until it runs or you clear it. unref() does not change that. The timers of node:timers and node:timers/promises are the global timers, so they count too.
  • An upgrade that a client has not claimed yet.

This matches wrangler dev. For the measurements, see the implementation notes.

  • A pending timer, an outbound WebSocket, or a fetch() also keeps an object that has no WebSocket. Orange keeps its actor awake until the work ends, so the timer runs in the same instance.
  • Only work that your Worker code starts counts. The timers and sockets of Orange and its packages do not keep an object awake.
  • When ctx.abort() resets an object, or its actor stops, Orange cancels the timers of the old instance, as workerd does. Their callbacks never run against an instance that is gone.

Clear each setInterval() when the object has no more work for it. An interval that is never cleared is always pending. It keeps the instance and its Rivet Actor awake forever, with or without a socket. The actor keeps its memory and counts toward the runner’s limit of awake objects. To find such an interval, see Find what keeps an object awake.

When the runtime stops, it closes each accepted WebSocket with 1012 Service Restart, at random times in a window of 10 ms per socket, up to 10 seconds. Clients do not all reconnect at once. Standard WebSockets close at once.

Find what keeps an object awake

Orange prints one line for each hibernation event of an object when RivetKit’s info logs are on. Run orange dev --log-level info, or set RIVET_LOG_LEVEL=info for orange serve.

[durable-object] soft hibernation actor_id=<id> event=hibernated
[durable-object] soft hibernation actor_id=<id> event=woke
[durable-object] soft hibernation actor_id=<id> event=held class=Room idle_ms=10004 held_by=timer:1 timers=setInterval(3000ms):1

The held line means that no event of the object started or ended for the idle timeout, and the object still runs.

  • idle_ms is the time since the last event started or ended. For a handler that still runs, it is how long the handler has run.
  • held_by names each cause with its count.
CauseWhat holds the object
timerA pending setTimeout() or setInterval() callback. timers lists each kind with its delay and count.
standard-websocketAn open WebSocket that the object accepted with accept().
outbound-websocketAn open WebSocket that the object opened, with new WebSocket() or through a Durable Object stub.
fetchA fetch() whose response has not arrived.
wait-untilA ctx.waitUntil() promise that has not settled.
eventA request, message, or alarm handler that still runs.
rpc-sessionAn open RPC session with a caller.
rpc-resultAn RPC result that a caller has not collected. It expires after 30 seconds.
unclaimed-upgradeA WebSocket upgrade whose client has not connected. It expires after 30 seconds.
keep-awakeWork that Orange runs for the object, such as a Workflow step.

When Orange prints the line:

  • First when the idle time reaches 10 seconds, then at 20, 40, and so on. The interval doubles up to 10 minutes.
  • An event that starts or ends starts the sequence again, so the next line comes 10 seconds after it.
  • A setInterval() that is never cleared shows in every held line, for as long as the object runs, as held_by=timer:1 timers=setInterval(3000ms):1.
  • Only for an object that has a WebSocket. An object with no WebSocket prints no held line, and with info logs, its actor’s sleep never appears.

Without info logs, Orange counts the causes and starts no report timer.

Storage

SQL

  • ctx.storage.sql.exec() runs synchronously on the actor’s SQLite database. Cursors support next(), toArray(), one(), raw rows, and duplicate column names.
  • SQL errors carry SQLite’s message and result code, as in workerd, for example no such table: x: SQLITE_ERROR.
  • databaseSize uses workerd’s formula: allocated pages minus free pages, times the page size.
  • The cursor reads all rows before it returns. rowsRead counts returned rows, not scanned rows. rowsWritten counts the changes of one INSERT, UPDATE, DELETE, or REPLACE, and is 0 otherwise.
  • A script with more than one statement runs every statement and returns the rows of each. Workerd returns the last statement’s rows. Bindings on such a script are rejected.
  • BEGIN, COMMIT, ROLLBACK, SAVEPOINT, and RELEASE through exec() fail. Use transactionSync() or transaction().

Key-value storage

  • ctx.storage.kv has synchronous get(), put(), delete(), and list() on the same database and transaction as SQL.
  • The async API has get(), put(), delete(), list(), and deleteAll(). Multi-key calls have no key-count limit.
  • Values use V8 serialization, so cycles, maps, sets, and typed arrays keep their shape. Each read returns a new copy.
  • Keys sort by their UTF-8 bytes. list() supports start, startAfter, end, prefix, reverse, and limit.
  • deleteAll() removes every user table, view, and trigger, and the stored alarm.
  • noCache and allowConcurrency have no effect. allowUnconfirmed: true keeps a write from holding the output gate.

Transactions and gates

  • Writes in one JavaScript turn commit together, as in workerd.
  • transactionSync() runs its closure in a savepoint. A thrown error rolls back the closure’s writes and is thrown again.
  • transaction() keeps one transaction open across await. New events wait until it ends.
  • Input gate: while blockConcurrencyWhile() or transaction() runs, new fetch, RPC, alarm, and WebSocket events wait.
  • Output gate: a response, a fetch(), an RPC call, or a WebSocket send() waits until the writes made before it are durable. If a commit fails, the held output is never sent, and the object resets.
  • blockConcurrencyWhile() times out after 30 seconds. A rejection or a timeout resets the object.
  • ctx.storage.sync() resolves when Rivet confirms every earlier write.

Reserved tables

Orange keeps its own data in the object’s SQLite database:

  • _cf_KV, _cf_ALARM, and _cf_METADATA, as workerd does.
  • _rivet_ tables for RivetKit and Orange.

These tables appear in sqlite_master. SQL that reads or changes them fails with not authorized. Do not name your own tables with these prefixes.

Alarms

  • alarm() gets scheduledTime, retryCount, and isRetry.
  • Only one alarm() runs at a time for an object.
  • When alarm() throws, Orange retries up to 6 times, after 2, 4, 8, 16, 32, and 64 seconds, plus up to 25% jitter. A setAlarm() or deleteAlarm() during the handler replaces the retry.
  • getAlarm() returns null while the alarm runs, unless the handler set a new one.
  • After construction, setAlarm() rejects a class with no alarm() method.
  • An alarm that comes due while orange dev is stopped does not run.

RPC and fetch

  • Calls from one Worker to one object arrive in the order you make them.
  • An error that your method or fetch() throws rejects only that call. The stub stays usable.
  • When the object resets, for example after ctx.abort(), the stub breaks. Its pending and later calls reject, and the error has durableObjectReset: true. Call get() or getByName() again for a new stub.
  • A response with Content-Encoding: gzip or br is compressed unless it sets encodeBody: "manual".
  • The object sees the request that the Worker sent, including OPTIONS requests and request.cf.
  • A pipelined call waits for its parent result, which adds round trips.
  • A Durable Object stub cannot be sent over RPC. Send the ID string.

WebSockets

  • new WebSocketPair(), accept(), and a status 101 response work as on Cloudflare.
  • ctx.acceptWebSocket(), tags, getWebSockets(), getTags(), serializeAttachment(), and deserializeAttachment() work.
  • webSocketMessage(), webSocketClose(), and webSocketError() get events for accepted sockets. Binary messages are ArrayBuffer values.
  • setWebSocketAutoResponse() answers a matching text frame without building an instance or resetting the idle timer.
  • setHibernatableWebSocketEventTimeout() accepts up to 2,147,483,647 ms (about 24.8 days), the longest Node.js timer. It cannot stop synchronous JavaScript.
  • Orange does not copy Cloudflare’s limits on tags, sockets per object, attachment size, or automatic-response size. Rivet’s transport limit applies to a message.

WebSocket hibernation

Orange emulates hibernation inside a running Rivet Actor, because Rivet 3 has paused Rivet WebSocket hibernation:

  • A socket host in the actor owns each accepted socket and outlives the instance.
  • After 10 seconds with no event, Orange drops the instance. The sockets stay open.
  • The next message, close, or error builds a new instance. Before the constructor runs, Orange restores each socket with its tags, attachment, and automatic-response timestamp. Events that arrive during construction wait and keep their order.

These differences from Cloudflare come from running hibernation without Rivet WebSocket hibernation. Orange cannot remove them.

A connected object is always a live actor

A hibernated object frees its instance, but its Rivet Actor, its sockets, and their state stay in memory. Cost grows with the number of objects that have an open socket, not with the number of objects that run code.

Measured on the packaged CLI, with one socket for each object and every object hibernated:

Connected objectsJavaScript heap for eachRuntime process memory for eachEngine process memory for each
1,00045 KB420 KB610 KB
10,00044 KB299 KB431 KB

For where the runtime’s memory goes, see the implementation notes.

One runner holds at most 4,000 awake objects

The Rivet Engine setting pegboard.sqlite_sessions limits the actors with an open SQLite database on one runner connection. Each connected object is one. Its default is 4,000. The next object does not start: its first request waits and then fails.

  • For orange dev, set RIVET__PEGBOARD__SQLITE_SESSIONS in the environment. Orange passes it to the Engine that it starts. With 20,000, one orange dev held 10,001 connected objects.
  • For a self-hosted Engine, set pegboard.sqlite_sessions in the Engine’s configuration.

Runner churn closes the sockets

Rivet cannot move an actor to another runner without closing its sockets, so a connected actor cannot move.

On Cloudflare, a code update keeps the sockets of a hibernated object connected by default since deferred code updates. A forced update or a runtime update disconnects them. On Orange, each of these events closes sockets.

Measured with two orange serve runners on one Engine and 40 objects:

EventThe client’s closeWhen the object answers again
A runner stops for a deploy, a scale-down, or a drain1012 Service Restart in 0.2 seconds. With many sockets, the closes spread over up to 10 seconds.A new connection through another runner got its reply in 0.03 to 0.33 seconds.
A runner crashes, and the client connected through it1006 at onceAt once when the object runs on another runner.
A runner crashes, and the object ran on it1012 Service Restart in 0.1 secondsOnly after the Engine judges the runner lost. Until then, a request to the object waits.
The Engine stops1012 Service Restart at onceAfter the Engine is back.

Orange gives 1012 only when Rivet’s error says that the runner or the Engine went away, such as ups.consumer_closed. Any other Rivet error, such as actor.not_found or guard.rate_limit, reaches the client as Rivet sent it: 1011 and <group>.<code>#<ray id>. Find the ray ID in the Engine’s logs.

The object’s storage survived each event. A client must connect again after any close that it did not ask for. It must wait a random time before each attempt, so that the clients of a stopped runner do not all return at once.

Facets

ctx.facets runs child objects that have their own SQLite storage. Orange supports the part that the agents SDK uses.

APIStatus
get(name, callback)Supported. class takes a class from ctx.exports or a namespace. id takes a DurableObjectId.
RPC and fetch() on the facet stubSupported
abort(name, reason)Supported
delete(name)Supported
clone(src, dst)Supported. Copies rows in the background. See Clone.
  • Each facet is a child Rivet Actor. Any exported class works. It needs no binding and no migration.
  • get() starts nothing. The first call through the stub runs the callback and starts the facet.
  • After abort() or delete(), the next get() runs its callback again, so a new class or id takes effect. After abort(), the new instance sees the old storage.
  • delete() fails calls with Facet was deleted. The next get() gets empty storage.
  • A facet without an id gets its parent’s ctx.id.
  • Facets nest to a depth of 4, including the root.
  • setAlarm() in a facet fails with Facets currently cannot set alarms.

Clone

clone(src, dst) replaces the storage of dst and its children with a copy of src. Cloudflare copies the SQLite files in the call. Orange cannot reach another actor’s files, so it copies rows:

  1. clone() returns at once. Calls to dst fail with Facet was cloned-over.
  2. The parent stores a clone record before its next output.
  3. src reads all its rows in one synchronous call and sends them in chunks.
  4. dst applies the chunks in one transaction.
  5. Calls to src and dst wait until the copy ends.

What you can rely on:

  • dst holds the data of src as of the clone() call. Calls to dst always see the copy.
  • The copy is whole or absent. It survives a restart, a crash, and the parent’s ctx.abort().
  • The copy is exact SQL: rowids, AUTOINCREMENT counters, generated columns, triggers, views, FTS5 tables, and value types.
  • clone(name, name) aborts the facet and keeps its storage. A src that never ran empties dst. At the maximum depth, clone() does nothing.
CaseCloudflareOrange
src is inside storage.transaction()Copies the state before the transactionWaits for the transaction, then copies
Copy costA file copysrc reads every row in one synchronous call, which blocks the runtime process
Facets created before clone supportCopiedTheir own rows are copied. Their children are not.
Infrastructure failure during the copyNot possibleCalls to dst fail until the parent restarts and runs the copy again

Measured cost of one clone() in orange dev on an Apple M-series Mac, for a facet of 256 KiB blobs. “Longest block” is the longest time the process answered no other request. With no clone, it is about 6 ms.

Facet sizeClone timeLongest blockExtra process memory
1 MB143 ms39 ms121 MB
10 MB537 ms37 ms252 MB
100 MB5.9 s1.5 s703 MB

Limits

Orange does not copy Cloudflare’s quotas. Rivet’s limits apply:

WhatLimitWhat happens over it
One storage write128 MiB of changed SQLite pages in one commitThe write throws a RangeError that names the limit.
One RPC call32 MiB on the wire. Binary data counts 4/3 of its size.The call throws a RangeError. Send large data with stub.fetch(), which streams.
blockConcurrencyWhile()30 secondsThe object resets.
Idle time before the instance or actor stops10 secondsSee How an object runs.
Alarm retries6The alarm is dropped.
Facet depth4, including the rootclone() does nothing at the maximum depth.
Awake objects on one runner4,000 by default. Set RIVET__PEGBOARD__SQLITE_SESSIONS to change it.The next object does not start. See One runner holds at most 4,000 awake objects.
Storage per objectNo limit set by Orange

All synchronous SQL runs on the one Node.js runtime thread, so a long query delays other Workers and objects in that process.

Who can call an object

Every Orange actor is a Rivet Actor in your project’s namespace: your Durable Objects, facets, and the actors of KV, D1, R2, Cache, Rate Limiting, Queues, Cron Triggers, and Workflows.

  • The Rivet gateway checks every call with the caller’s Rivet token and its grants. Orange adds no check of its own.
  • So your other Rivet Actors and clients in the same project can call Orange actors directly with a token that has an actor_gateway grant. On Cloudflare, only the Worker can reach a Durable Object.
  • A caller with the grant can also set the request that an object’s fetch() sees, including request.cf.
  • To keep a client away from your objects, give it a token without an actor_gateway grant, or scope its grant to specific actor IDs.
  • orange dev and orange serve drop every x-open-orange-internal-* header that a client sends to the Worker.
  • The Rivet token never reaches your object’s fetch(), WebSocket, or RPC code.
  • Local development is not a security boundary. The local Engine’s admin token is default, and any process on the machine can use it.

Debug an object

  1. Run orange dev. The binding table shows each Durable Object binding as Rivet Actor · <ClassName>.
  2. To see RivetKit’s own logs, run orange dev --log-level debug. By default, orange dev shows only RivetKit errors.
  3. Find the actor of an object. Its name is the class in kebab case and a short hash, for example greeter-5c8cd02c. Its key is the object’s ID.
SymptomCauseFix
A field of your class is emptyThe instance was dropped or the actor sleptKeep the value in ctx.storage.
An object never hibernates or sleepsA timer, an outbound WebSocket, a fetch(), or other work still holds itSee Find what keeps an object awake.
A request to a new object waits, then failsThe runner already holds 4,000 awake objectsRaise the limit. See One runner holds at most 4,000 awake objects.
A client’s WebSocket closes with 1012A runner or the Engine went awayConnect again after a random wait. See Runner churn closes the sockets.
A client’s WebSocket closes with 1011 and <group>.<code>#<ray id>Rivet returned an errorFind the ray ID in the Engine’s logs.
Calls reject with durableObjectReset: trueThe object reset, for example after ctx.abort() or a failed commitCall get() or getByName() again.
RangeError that names 128 MiBOne write changed too many pagesSplit the write.
RangeError that names 32 MiBOne RPC call is too largeSend the data with stub.fetch().
SQL fails with not authorizedThe SQL reads or changes a _cf_ or _rivet_ tableRename your table, and skip these tables in a dump.
Synchronous storage cannot run while another event's storage.transaction() is openAn event that was already running used sync storage during another event’s transaction()Use the async storage API in that code path.
A fetch() response body is cut shortThe response reuses the unread request bodyRead the request body before you build the response.

Under orange dev, local Engine state is in .open-orange/rivetkit/ in your project. The Engine’s log gets half of the free disk there, from 256 MiB to 4 GiB, and orange dev stops with an error when less than 512 MiB is free. Set RIVET__STORAGE__LOG_SHARD__MAX_DISK_BYTES in the shell to pick the size yourself. orange doctor reports the free space in its disk check, with the same rule: it counts the free disk and the log of the Worker’s own storage directory.

Deviations

  • RivetKit’s _rivet_ tables share the object’s database. Code that dumps every table in sqlite_master, such as starbasedb’s /export/dump, fails.
  • Orange blocks _cf_ and _rivet_ names with a text check, not a SQLite authorizer. There is no SQL function or PRAGMA allow-list. DELETE and UPDATE with LIMIT fail.
  • ctx.storage.kv.list() reads the matching rows when you call it, so later writes do not appear in the iterator.
  • The input gate holds only new events. An event that was already running can continue during another event’s critical section.
  • The output gate does not hold body chunks streamed after the headers, outbound new WebSocket() setup, or output from callbacks that run outside the object’s async context.
  • The alarm row commits before Orange updates the Rivet schedule. Startup repairs a schedule that a crash left missing or stale.
  • ctx.abort() and broken storage restart the complete Rivet actor. About 1 call in 600 that overlaps ctx.abort() fails with 503.
  • Every idle object stops after 10 seconds without events, so instance fields are lost sooner than on Cloudflare.
  • An object that closes a standard WebSocket itself stays awake.
  • A stub has no request owner, so a stub kept in a global works in later requests.
  • Classes from new_classes support ctx.storage.sql, where Cloudflare throws.
  • In a new parent instance, the first get() of a facet applies its callback’s class even when the facet actor is still awake.
  • A facet whose stored class is no longer exported cannot start. Call delete().
  • A parent’s ctx.abort() and storage.deleteAll() do not reach its facets. ctx.facets.abort() restarts only the named facet.
  • Each facet call goes through the Rivet gateway. A class change restarts the facet actor.
  • delete() destroys the old facet actor in the background. If that fails, the actor stays. Facets nested in a deleted facet are not destroyed.
  • A facet stub cannot be sent over RPC.

Not supported

  • Container images other than Cloudflare Sandbox images.
  • Per-request ctx.props values.
  • Point-in-time recovery with getBookmarkForTime() and onNextSessionRestoreBookmark().
  • ctx.restore() and ctx.configureReadReplication().
  • stub.connect() and the connect() handler.
  • Bindings to another Worker with environment or namespace_id.
  • Moving existing objects with a transferred_classes migration.
  • Durable Object exports configuration.

Their types stay in the generated contract, and each placeholder throws an error that says it is not supported.

Conformance

  • src/runtime/durable-object-facets.test.ts and src/runtime/durable-object-facet-clone.test.ts run facets and workerd’s facet clone test on a real orange dev.
  • tests/accepted-websockets.test.ts runs hibernation, the work that keeps an object awake, and the held line on orange dev with a real Rivet Engine.
  • The conformance package runs workerd’s upstream tests against orange dev and ports workerd’s C++ Durable Object tests for alarms, IDs, the input gate, SQL, storage, and WebSockets.
  • Generated type checks compare Orange’s Durable Object types with the pinned Wrangler declarations.

The implementation notes list every test suite.

Compatibility

From the latest conformance run. Scores on the compatibility page; every binding on the status page.

Durable Objects

Partial durable_objectsmigrations

Runs on Rivet Actors with SQLite storage and alarms.

WebSocket hibernation is emulated. After 10 idle seconds, Orange drops the object's instance and keeps the actor and its accepted WebSockets open. The next event builds a new instance, and an auto-response frame is answered without one. The actor sleeps only when it has no open accepted WebSocket. A class from another Worker (script_name) runs when that Worker is in the same orange dev session. Rivet's _rivet_ tables are reserved in the object's SQLite.

Scales by Object ID. One actor per object id. Add ids to add capacity.

Recorded results

WorksStorage API: get, put, list, delete, transaction, sync KVAvailable.Same, stored in the object's SQLite. 4/4 pass
Results
  • synthetic--do-storage-test Pass 24/24
  • api-tests--actor-kv-test Pass 1/1
  • api-tests--sync-kv-test Pass 1/1
  • lifecycle: storage: a value survives the object's sleep Pass workerd pass
WorksKV-backed classes (new_classes)A separate storage backend with no SQL.Accepted, and stored in SQLite like every other class, so the class can also use SQL. There is no import of data from a Cloudflare KV-backed object. 2/2 pass
What to do
New code runs unchanged. To move a live object off Cloudflare, read its data out through its own API.
Results
  • lifecycle: KV-backed class: the storage API keeps a value Pass workerd pass
  • lifecycle: KV-backed class: SQL is available Pass workerd fail
WorksObject ids and input gatesAvailable.Same. 2/2 pass · 1 not run
Results
  • synthetic--do-id-test Pass 2/2
  • synthetic--do-input-gate-test Pass 5/5
  • api-tests--actor-block-concurrency-depth-test Not run
WorksAlarmsAn alarm fires on time, wakes a sleeping object, and survives a restart.Same, when the process is up at the alarm's time. 4/4 pass
Results
  • synthetic--do-alarm-test Pass 17/17
  • lifecycle: alarm: fires on time Pass workerd pass
  • lifecycle: alarm: wakes a sleeping object with no request Pass workerd pass
  • lifecycle: alarm: a future alarm survives a restart Pass workerd pass
WorksAn alarm due while the process is downFires when the runtime comes back.Same. It fires after a restart, with no request. 1/1 pass
Results
  • lifecycle: alarm: an alarm due while the process is down fires after a restart, with no request Pass workerd pass
PartialRPC and entrypointsAvailable.Stubs, WorkerEntrypoint, RpcTarget, and streams work. Calls from one process to one object keep the order they were made in, whichever stub made them, as in workerd. Other failing tests are in the report. 2/3 pass
Results
  • workerd tests: RPC + entrypoints Partial 154/179
  • api-tests--request-signal-enabled > abortIfClientAbandonsRequest Pass
  • api-tests--request-signal-enabled > abortedRequestDoesNotAbortSubrequest Pass
WorksWebSockets through an objectAvailable.Same, while the object stays awake. 2/2 pass
Results
  • synthetic--do-websocket-test Pass 11/11
  • workers-chat-demo: two clients chat in a room, and history is kept Pass
PartialWebSocket hibernationA socket stays open while the object sleeps, and a message wakes the object.Orange emulates hibernation, and real hibernation is coming soon. When an object with an open accepted WebSocket is idle, Orange drops the object and its in-memory state and keeps the socket open. The next message builds a new instance of the object. The Rivet actor stays running the whole time, and it sleeps once the last socket closes. A client stays connected, but the actor does not sleep while it does. 16/19 pass
Why
Rivet 3 has paused WebSocket hibernation, so an actor cannot sleep with a socket open. Orange hibernates the object inside the running actor instead.
What to do
Have clients reconnect on any close, and don't depend on one close code. Keep anything that must survive hibernation or a restart in storage. Close sockets you no longer need, so the actor can sleep.
What to plan for
  • An object with an open socket is always a running actor and does not scale to zero: the actor, its sockets, and their state stay in memory. Before the emulation, an idle awake object cost about 1.1 MB in total, in Orange and the Engine, and each idle socket on one object cost about 0.2 MB. About half of an object's cost stayed after it slept. Measured through orange serve on one machine, with 1,000 idle objects or 1,000 idle sockets on one object, and not measured again with the emulation. It is not a limit.
  • A deploy closes open sockets. On one cluster, each client reconnected on the first try: 1012 on 4 sockets and 1011 on 1. The object is a new instance, and storage survives. In-memory state and the old sockets do not. Whether the object gets webSocketClose during a rollout is not measured.
  • Old and new runners overlap for about 100 s during a rollout, and the Engine can place new objects on the old runner then. Those clients get cut off soon after opening.
  • Under orange dev, a restart closes open sockets with 1012 and a crash with 1006. In both cases a reconnect works and storage survives.
  • Orange pings every network socket every 30 s, because Cloudflare closes idle sockets on pool URLs at 125 s with 1006. With pings, idle sockets stayed open until a rollout closed them at 695 s. A Worker and its objects never see the pings. wrangler dev kept idle sockets open for 360 s with no pings. Real Cloudflare is not measured: its docs say idle sockets get closed and recommend a heartbeat.
  • A client whose connection is cut gives the object webSocketClose with 1006 within about 1 s. A client that stays connected but stops answering pings is closed with 1006 at about 90 s. The object sleeps after both.
  • webSocketClose gets 1001 "gateway disconnected" where a client closed cleanly with 1000. wrangler dev passes the client's code.
  • The object hibernates, but its actor does not sleep with a socket open. Real projects' steps that wait for every actor to sleep fail with Some actor was still awake. The real-project results show each of those steps again under a sleep check that also accepts the emulation.
Results
  • synthetic--do-websocket-hibernation-test AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | + actual - expected | Partial 2/3
  • lifecycle: hibernation: a socket stays open through the object's sleep, and a message wakes it Some actor was still awake 45000ms after the last client activity Fail workerd pass
  • lifecycle: open socket: an idle socket stays open past the sleep timeout Pass workerd pass
  • lifecycle: open socket: an idle object drops its in-memory state while its socket stays open Pass workerd pass
  • lifecycle: open socket: the object sleeps after its last socket closes Pass workerd pass
  • lifecycle: open socket: webSocketClose gets the client's close code /closed?room=k4 did not reach the expected state: {"code":1001} Fail workerd pass
  • lifecycle: open socket: a restart closes it with 1012, and a reconnect finds storage Pass workerd fail
  • lifecycle: open socket: a crash closes it with 1006, and a reconnect finds storage Pass workerd pass
  • cluster-websocket: an idle socket on a pool URL, with no pings, closes at 125 s with 1006 Pass
  • cluster-websocket: with pings, idle sockets stay open until a rollout closes them at 695 s Pass
  • cluster-websocket: a rollout closes sockets with 1012, and each client reconnects on the first try Pass
  • cluster-websocket: a socket can also close with 1011 when its object's generation ends Pass
  • cluster-websocket: after a rollout the object is a new instance, and an accepted socket's storage is kept Pass
  • cluster-websocket: a cut client gives the object webSocketClose 1006 within about 1 s, and the object sleeps Pass
  • cluster-websocket: a client that stops answering pings is closed with 1006 at about 90 s, and the object sleeps Pass
  • cluster-websocket: wrangler dev keeps idle sockets open for 360 s with no pings Pass
  • keep-awake-memory: 1,000 objects with one idle socket each cost about 1.1 MB per object Pass
  • keep-awake-memory: 1,000 idle sockets on one object cost about 0.2 MB per socket after a long hold Pass
  • keep-awake-memory: about half of an object's cost stays after it sleeps Pass

In the feature matrix

Partialdurable_objects (class in this Worker)Binds a Durable Object class from this Worker. The class can use the SQLite or the KV storage backend.Binds the class when a new_sqlite_classes migration creates it. The namespace runs on Rivet Actors. 4 files
Notes
Classes from new_classes (KV backend) fail config load with Unsupported Durable Object migration fields. See the Durable Object entries for API details.
Source
src/config/load-wrangler.ts:1036-1043src/config/load-wrangler.ts:921-944packages/runtime-node/src/runner.ts:681-693src/config/load-wrangler.test.ts:92-134
Supporteddurable_objects (class in another Worker)Binds a Durable Object class that another Worker exports, with script_name.The class runs in the Worker named by script_name, in the same orange dev session and on the same Rivet Engine. Stubs from both Workers reach one object. 3 files
Notes
A script_name outside the session gives a namespace whose stubs answer fetch() with 503 and throw on RPC, like Wrangler's dev registry. A binding with only environment or namespace_id still loads a stub.
Source
packages/runtime-node/src/session/client.tspackages/runtime-node/src/session/server.tssrc/runtime/multi-worker.test.ts
SupportedDurable Object fetch()stub.fetch() calls the Durable Object's fetch(request) through the input gate.stub.fetch() runs the instance fetch(request) as an ordered event inside the input gate. 1/1 pass · 2 files
Notes
A class without fetch() fails with Error Handler does not export a fetch() function. A non-Response result fails with TypeError Incorrect type for Promise: the Promise did not resolve to 'Response'. Both match workerd.
Tests
  • api-tests--actor-stub-test › main Pass
Source
packages/runtime-node/src/bindings/durable-objects/fetch.ts:128-207src/runtime/durable-object-rpc-fetch.test.ts
SupportedDurable Object alarm()alarm(alarmInfo) runs at the time from setAlarm(), with retries on failure.Runs alarm({ retryCount, isRetry, scheduledTime }) at the stored time, with up to 6 retries. 2/2 pass · 2 files
Notes
setAlarm() without an alarm() handler fails with Your Durable Object class must have an alarm() handler in order to call setAlarm(). All 17 synthetic do-alarm tests pass.
Tests
  • synthetic--do-alarm-test › throwingHandlerIsRetried Pass
  • api-tests--actor-alarms-test › test Pass
Source
packages/runtime-node/src/bindings/durable-objects/alarm-storage.ts:305-341packages/runtime-node/src/bindings/durable-objects/alarm-storage.ts:232-240
SupportedDurable Object webSocketMessage()webSocketMessage(ws, message) receives frames on WebSockets accepted with ctx.acceptWebSocket().Calls webSocketMessage(ws, message) for each frame that is not an auto-response, as an input-gated event. 2/2 pass · 1 file
Notes
A missing method silently drops the event, like workerd. The handler runs on the shared Node thread, so a synchronous loop cannot be stopped by the event timeout.
Tests
  • synthetic--do-websocket-test › clientStringDispatchedToHandler Pass
  • synthetic--do-websocket-test › clientBinaryDispatchedToHandler Pass
Source
packages/runtime-node/src/bindings/durable-objects/hibernatable-websocket-events.ts:168-181
PartialDurable Object webSocketClose()webSocketClose(ws, code, reason, wasClean) runs when a hibernatable WebSocket closes.Calls webSocketClose(ws, code, reason, wasClean) once. The socket stays listed until the handler settles. 2/3 pass · 4 files
Notes
A client's close code can be lost in the Rivet gateway: in the recorded lifecycle probe, a clean client close reached webSocketClose as 1001, not 1000. A missing method silently drops the event.
Tests
  • synthetic--do-websocket-hibernation-test › closeWhileHibernatedKeepsCodeAndReason AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | + actual - expected | Fail · Behavior differs
  • synthetic--do-websocket-test › clientCloseDispatchedToHandler Pass
  • api-tests--websocket-hibernation › test Pass
Source
packages/runtime-node/src/bindings/durable-objects/hibernatable-websocket-events.ts:138-153packages/runtime-node/src/bindings/durable-objects/websocket.ts:276-288packages/cloudflare-conformance/probes/lifecycle/scenario.ts:90-94packages/cloudflare-conformance/probes/lifecycle/result.json:87-91
SupportedDurable Object webSocketError()webSocketError(ws, error) runs when a hibernatable WebSocket fails.Calls webSocketError(ws, error) once and releases the socket. 1 file
Notes
No conformance test triggers a WebSocket error.
Not verified by a test
No test drives the error path. The claim is read from code.
Source
packages/runtime-node/src/bindings/durable-objects/hibernatable-websocket-events.ts:154-166
StubDurable Object connect()stub.connect() opens a TCP-style socket to the Durable Object's connect() handler.stub.connect() throws DurableObjectStub.connect is not implemented by OpenOrange. 1 file
Notes
A connect() method on a Durable Object class never runs.
Not verified by a test
Whether Cloudflare production exposes Durable Object connect() is not checked.
Source
packages/runtime-node/src/bindings/durable-objects/namespace.ts:362-369

Durable Object Facets

Partial

Runs on A child Rivet Actor per facet.

get, abort, delete, clone, and string IDs work, which covers the agents SDK.

Recorded results

WorksDurable Object facetsAvailable.A child Rivet Actor per facet, with abort, class swap, and delete. 1/1 pass
Results
  • example: facets-loader Pass

In the feature matrix

Partialctx.facetsStarts and manages child objects (facets) that share the parent's storage.ctx.facets.get(), abort(), delete(), and clone() work. Each facet is a child Rivet Actor with its own SQLite storage, hosted by one generic actor definition, so any exported class works without a binding or migration. A string id becomes the facet's ctx.id, as in workerd. clone() returns at once and copies one synchronous row snapshot of the source facet and its children in the background; later calls to the source and destination wait for the copy. 0/1 pass · 5 files
Notes
clone() matches workerd's facet cloning test, including restarts, and survives ctx.abort() and a crash during the copy. It copies a source inside storage.transaction() after the commit, where workerd copies the state before it, and its synchronous read blocks the runtime process (1.6 s for 100 MB). Facets nest to a depth of 4 and cannot set alarms. ctx.facets is still excluded from the generated type checks. See docs/modules/durable-objects.mdx.
Tests
  • api-tests--persistent-stubs-test › persistentFacetStubs NotSupportedError: DurableObjectState.restore is not implemented Fail · Not-implemented API
Source
packages/runtime-node/src/bindings/durable-objects/state.ts:408-411packages/runtime-node/src/bindings/durable-objects/facets.ts:577-651packages/runtime-node/src/bindings/durable-objects/facet-clone.ts:87-288src/runtime/durable-object-facet-clone.test.ts:19-161src/runtime/durable-object-facets.test.ts:86-163