Durable Objects
Declare `durable_objects` and `migrations` in runlot.json; export the class from your worker and reach one instance by name. Each object is a single-threaded actor with its own storage.
A Durable Object is one named instance of a class, with storage only it can reach and only one of it running at a time. Declare the binding and the class in runlot.json, export the class from your worker, and reach an instance by name.
{
"name": "my-app",
"main": "src/index.ts",
"durable_objects": {
"bindings": [{ "name": "ROOMS", "class_name": "Room" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }]
}import { DurableObject } from "cloudflare:workers";
export class Room extends DurableObject {
async join(user: string): Promise<number> {
this.ctx.storage.sql.exec("INSERT OR IGNORE INTO members (name) VALUES (?)", user);
return this.ctx.storage.sql.exec("SELECT count(*) AS n FROM members").one().n as number;
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const name = new URL(request.url).searchParams.get("room") ?? "lobby";
const room = env.ROOMS.getByName(name);
return Response.json({ members: await room.join("me") });
},
};These two blocks are spelled in snake_case — durable_objects, class_name, new_sqlite_classes — while the rest of runlot.json camelCases. That is deliberate: a migration history is copied across as a whole rather than typed a key at a time, and renaming the keys would make that copy silently wrong. The camelCase spellings are refused by name, so a guess is answered rather than dropped.
runlot deploy prints what it is about to register.
Durable Objects: env.ROOMS → Room
Durable Object migrations: v1 (creates Room)Types
cloudflare:workers is provided by the runtime — the bundler leaves it external, so there is nothing to install for it to run. Its types are a separate package, and a new project does not have it:
npm i -D @cloudflare/workers-types{
"compilerOptions": {
"types": ["@cloudflare/workers-types"]
}
}That is what declares the cloudflare:workers module and with it DurableObject, along with DurableObjectState, DurableObjectStub, AlarmInvocationInfo and WebSocketPair. Without it the examples below run and do not typecheck.
Declaring them
main is required. A Durable Object is a class the worker exports, and a static-only deployment exports none.
Bindings
name | What the worker reads it as — env.ROOMS. Any JavaScript identifier of at most 64 characters, in any case. |
class_name | The class the worker exports. Two bindings may point at one class. It has to be created by one of the migrations steps. |
script_name | A binding to a class exported by a different worker, which runlot does not have. Accepted into the schema and refused with a value, rather than dropped in silence. |
db, auth, storage, email, ai, vectors, assets, ASSETS, the JavaScript reserved words and the RUNLOT_ prefix are reserved binding names. A name already taken by a queue producer is refused. On framework: next, WORKER_SELF_REFERENCE is reserved as well, because the generated configuration renders OpenNext's own self-reference under it.
A class name cannot be a JavaScript reserved word. The generated entry module re-exports your worker's names explicitly, so a class called default produces a module that does not parse — and a module that does not parse is a deploy that succeeds and a worker that never boots.
Migrations
migrations is a top-level array rather than part of a binding, because a class outlives the binding that reached it. The array is the history and its order is the order it happened in. Nothing sorts it and nothing rewrites a tag.
- A tag is unique across the history and matches
^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$. - Every step has to create at least one class no earlier step created. A step that creates nothing is refused rather than ignored.
- Every binding's
class_namehas to appear in one of these steps. A class no binding reaches is accepted with a warning — it works, it costs a namespace, and the day someone wonders whyenv.ROOMSis undefined there is nothing in the record that says the binding was never written.
| Kind | |
|---|---|
new_sqlite_classes | Creates classes with ctx.storage.sql. |
new_classes | Creates classes without ctx.storage.sql — the KV API only. |
The two are not the same class. Both store on SQLite underneath, but enableSql is false for a class created as new_classes, and a class created as a KV class never gains a SQL surface. Which list a class first appeared in is the whole of the answer, for as long as the class exists. Write new_sqlite_classes unless you are carrying a history that already says otherwise.
Only creation is supported today. renamed_classes, deleted_classes and transferred_classes are refused by name, with the reason:
renamed_classes | A rename has to preserve the namespace's stored unique key rather than derive a new one from the new class name. Accepting it today would mint a fresh namespace and report success while the renamed class had no objects. |
deleted_classes | Deleting a class would leave its objects' storage with nothing to open it. Delete the project, or stop binding the class. |
transferred_classes | Transferring a class between workers needs cross-script namespaces, which runlot does not have. |
A dropped migration key is a deploy that reports success while the class the worker now exports has no objects under it, so each of these is an error rather than a warning.
Reaching an object
A binding is a namespace, not an object. The namespace turns a name or a fresh identifier into an id, and the id into a stub.
env.ROOMS.idFromName("lobby") | The same name always gives the same id. A name is identity, not geography — idFromName("room-tokyo") does not ask for Tokyo. |
env.ROOMS.newUniqueId() | A new object nobody else can name. Store the id yourself, with id.toString(). |
env.ROOMS.idFromString(hex) | Back from a stored id. |
env.ROOMS.get(id) | The stub. |
env.ROOMS.getByName("lobby") | idFromName then get, in one call. |
A stub carries fetch(request) and the RPC methods your class defines. Neither creates anything by itself — an object exists once your code reaches it, and the first reach is what places it.
const room = env.ROOMS.getByName("lobby");
await room.join("me"); // RPC
await room.fetch("https://do/state"); // fetch()RPC method arguments and return values are structured-cloneable; a method that is not defined on the class is a TypeError at the call, not a 404.
With RLS on, a method call runs as its caller: a signed-in request's user, or owner from a cron, queue or alarm. Chained calls such as room.counter().inc() and property reads such as room.counter().value still work, but each step waits for the one before it, so a chain costs one round trip per step. A method reached on an object that an earlier call returned does not carry the caller, and runs as anonymous inside the object.
Storage
ctx.storage is the object's own store. It is not the project database, it is not shared with any other object, and it is not reachable from psql.
The KV API
Available on every class.
await this.ctx.storage.put("score", 41);
const score = (await this.ctx.storage.get<number>("score")) ?? 0;
await this.ctx.storage.list({ prefix: "member:", limit: 100 });
await this.ctx.storage.delete("score");
await this.ctx.storage.transaction(async (txn) => { /* … */ });The SQL API
Available on classes created with new_sqlite_classes.
this.ctx.storage.sql.exec(
"CREATE TABLE IF NOT EXISTS members (name TEXT PRIMARY KEY, joined INTEGER)",
);
const rows = this.ctx.storage.sql
.exec("SELECT name FROM members ORDER BY joined DESC LIMIT ?", 20)
.toArray();
this.ctx.storage.transactionSync(() => {
this.ctx.storage.sql.exec("DELETE FROM members WHERE joined < ?", cutoff);
});exec is synchronous and returns a cursor — next(), toArray(), one(), raw(). transactionSync takes a synchronous callback and commits when it returns. sql.databaseSize is the store's size in bytes. The dialect is SQLite's, not PostgreSQL's.
The project database's 10-second statement ceiling does not apply here; the worker's own CPU limit does.
What persists
The store survives a redeploy, a rollback, and the project going idle and coming back. It is keyed by the namespace's stable identity rather than by the deployment, which is why a new version of your code opens the same store — and why renaming a class is not a thing you can do by editing class_name.
Alarms
One alarm per object, at one time. Setting a second one replaces the first.
export class Room extends DurableObject {
async schedule() {
await this.ctx.storage.setAlarm(Date.now() + 60_000);
}
async alarm(alarmInfo?: AlarmInvocationInfo) {
if (alarmInfo?.isRetry) {
// attempt alarmInfo.retryCount, for the alarm scheduled at alarmInfo.scheduledTime
}
await this.sweep();
}
}getAlarm() reads the pending time or null; deleteAlarm() cancels it. An alarm() that throws is retried: the first retry waits 2 seconds and each one after doubles, up to 6 attempts. alarmInfo carries isRetry, retryCount and scheduledTime, so a handler can tell a retry from a first run without keeping that state itself.
WebSockets
A Durable Object is where a connection's state belongs, and the runtime gives you two ways to hold one.
export class Room extends DurableObject {
async fetch(request: Request): Promise<Response> {
const pair = new WebSocketPair();
this.ctx.acceptWebSocket(pair[1], ["room:lobby"]);
pair[1].serializeAttachment({ user: "me" });
return new Response(null, { status: 101, webSocket: pair[0] });
}
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
for (const peer of this.ctx.getWebSockets("room:lobby")) peer.send(message);
}
webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {}
webSocketError(ws: WebSocket, error: unknown) {}
}ctx.acceptWebSocket(ws, tags?) | The hibernatable path. Frames arrive as webSocketMessage, webSocketClose and webSocketError methods on the class, not as listeners on the socket. |
ws.accept() | The plain path. Frames arrive as message and close events on the socket, and the handler that accepted it holds the connection for its whole life. |
The two are not interchangeable, and only the first has the surrounding API:
ctx.getWebSockets(tag?)returns the accepted sockets, filtered by a tag you passed toacceptWebSocket.ws.serializeAttachment(value)/ws.deserializeAttachment()keep a small structured value with the socket instead of in a field of the class.ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(request, response))answers a fixed ping message without running your code;ctx.getWebSocketAutoResponseTimestamp(ws)is when that last happened.ctx.getTags(ws)reads the tags back.
Hibernation across a process restart
A connection accepted with ctx.acceptWebSocket survives the object's process restarting. The client's connection terminates at the front, not inside the object's process, so a redeploy, the project stopping while idle, or a crash of the process the object runs in does not drop it. The object finds its sockets again in ctx.getWebSockets().
What crosses that restart with them: the tags passed to acceptWebSocket, the value stored with serializeAttachment, the difference between a text and a binary message, the order the messages arrived in, and the pair given to setWebSocketAutoResponse — which the front answers itself, so a keepalive never wakes the object.
ws.accept() is not part of this. The plain path is bound to its process by design, and it still closes when that process restarts. That is the difference between the two rows of the table above, and it is the reason to write ctx.acceptWebSocket even for a connection you expect to be short.
What survives is a restart of the object's process, not of every process. The front that holds the client connection, and the node agent above it, restart on their own occasions, and when one of them does, clients disconnect. That is a reconnect, not a transparent recovery, so a client that reconnects is still worth writing — against the platform this API comes from too.
Three bounds are worth knowing:
- An object that is not reading closes its client's TCP window rather than dropping messages. A slow object slows its own clients down; it does not lose what they sent.
- A delivery whose outcome is ambiguous — the process ended somewhere between the message going in and the handler returning — is never replayed. That one client is closed with
1011and the rest of the object carries on. An application that needs replay carries its own sequence numbers and acknowledgements. serializeAttachmentis capped at 16,384 bytes, the bound the platform this API comes from applies, and it is checked where the attachment is set rather than at the next adoption.
Where an object runs
An object is placed on first access, and it stays there. The node is chosen in the region of the front that received that first call — the nearest one there is — and nothing moves it afterwards. There is no failover: an object whose node is unreachable answers 503 until that node is back, and waiting is the whole of the remedy — a second copy elsewhere could not stay consistent with the store the original still holds.
Jurisdictions and location hints
env.ROOMS.get(id, { locationHint }) and env.ROOMS.jurisdiction(name) both narrow the first-placement choice, but they make different promises:
locationHint | Best effort. The accepted hints are wnam, enam, sam, weur, eeur, apac, apac-ne, apac-se, oc, afr and me. A hint only reorders nodes already eligible in the jurisdiction; it cannot add one. An unknown or differently-cased value is refused with 400 unknown_location_hint rather than silently ignored. |
jurisdiction | Hard. eu and us are supported — eu is the 27 member states and us is the United States, not North America. Unsupported names, including fedramp and fedramp-high, are refused rather than widened, because ignoring one could place the object exactly where the caller was avoiding. A node that has not declared a country satisfies no jurisdiction. |
A jurisdiction is decided once, on first access, and the stored one governs every call after that. Omitting it on a later call inherits the boundary rather than withdrawing it; naming a different one is refused.
An object's env.db is a network call
An object does not run beside your project's database. env.db from inside an object reaches the project's home over the network — a LAN round trip in the same region, the distance across regions. It is a real binding and it works; it is not free.
Keep the object's state in the object, and use env.db from an object for what is genuinely shared. Applications written for this platform already do.
Locally
runlot dev runs the same namespaces on the same runtime. See Running locally.
Watching them
The Durable Objects tab of your project reports how many objects the project has placed, against the two caps, with a per-node breakdown and the size those nodes have reported.
It does not report which ones. There is no object id on that screen and none in the API — the tab reports counts and sizes, not a listing of your objects. Your application is the only thing that knows what its ids mean.
Sizes and counts come from different places, and the screen says which is which. The count comes from runlot's placement records and is always current. Bytes and files are summed from what the holding nodes have reported. If none has reported, the screen shows no size at all; if only some have, it shows the known sum as at least that much and names the missing nodes. The timestamp is the oldest measurement in the sum, because that is the one that bounds how stale the total is. The file count is not the object count: an object that has never written has no file.
The same figures are on GET /v1/orgs/{org}/projects/{project}/durable-objects.
Limits
| Bindings per worker | 32 |
| Classes per project (across the whole history) | 32 |
| Binding and class name | 64 characters |
| Objects placed per project | 4,096 |
| Objects of one project per node | 4,096 |
Placing a new object once the project is at its cap is refused with 409 object_capacity. Objects that already exist go on resolving and being served — it is a limit on growth, not an outage. The per-node bound is separate and can refuse a placement while the project-wide cap still has room, so a project can hit object_capacity on one node before it reaches 4,096 objects overall.
Backups
Each node snapshots the object stores it holds, on its own clock. Your project's database ships its own backups on a third clock. None of those times are the same time. So the object side of a backup is a set of independently timed components, each carrying the moment it was taken, and it is marked as not atomic with the database — a screen that rendered one restore time for the pair would be showing a time that does not exist.
Those components are listed beside the database's generations, under objects, with a restorable flag: true only when every holder that needs one has a component covering it. A holder that has reported nothing at or before the chosen moment is named as missing, and its current bytes are never quietly substituted — restorable reads false instead. restorable is checked against a single moment (?at=, default now), not against whichever database generation you are about to pick — ask for it at that generation's own moment before you restore to it. And true means the catalog is complete, not that the restore is guaranteed to succeed: a node that is unreachable, or whose own archive turns out to be unreadable, can still fail the restore after it starts.
Restoring a project with Durable Objects restores both halves in one operation. Pick a database generation and call restore as you already would (runlot pg restore, or the API). If the project has objects, each object is restored from the component covering the moment you picked — you choose the database generation, and the matching object component is selected for you. Its current store is archived first, so the restore itself is reversible. An object that did not exist yet at that moment is removed rather than left pointing at a store that was never there. The two halves still share no clock (atomic stays false): each side is restored to its own component's own instant, not to one shared timestamp.
There is no psql into an object's store, and no plan to route one through: object tables are not in your project's PostgreSQL database.
Limitations
These keys and behaviours are not available. Each unsupported key is refused by name rather than ignored:
script_name. A namespace defined by a different worker needs cross-script namespaces, which runlot does not have. The key is refused with a value rather than dropped, because dropping it would bindenv.ROOMSto a class this worker does not export and the failure would arrive as a runtime error inside a request nobody is watching.renamed_classes,deleted_classes,transferred_classes. A history that carries one of them has to be trimmed, and trimming a history is a decision about your stored objects — which is exactly why it is not done for you.fedrampandfedramp-high. Refused, not widened.- A hibernating socket that outlives every process. A restart of the object's process is transparent; a restart of the front holding the client connection is not.
- An object beside its database.
env.dbfrom an object is a network call.
What is not here
- No object placement by you.
locationHintis a preference; runlot picks the node. - No move and no failover. First placement is permanent.
- No cross-project namespace: a binding reaches a class this project's own worker exports.
- No object id in the dashboard or the API.
- No atomic backup across objects and the database — each side restores to its own component's own instant — and no
psqlinto an object.