The answer. Workers are stateless and run anywhere; a Durable Object is the opposite on purpose. A class extends DurableObject; the runtime creates exactly one live instance per id, wherever the first request lands, and every request for that id goes to that instance, one at a time. That single-threading is the feature: a counter, a room, a per-tenant queue, a rate limiter or a lock needs no coordination code because there is only ever one of it. Each object has private storage: a SQLite database (ctx.storage.sql.exec) plus a key-value API over it, transactional, and recoverable to any point in the last 30 days. Since July 2026 new namespaces must be SQLite-backed (new_sqlite_classes); the old key-value backend exists only for accounts that already had one. Objects can be reached by RPC (plain methods on the stub), by fetch(), over WebSockets, and by alarms.
The pattern.
{ "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<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
ctx.storage.sql.exec("create table if not exists msgs (id integer primary key, body text, at integer)");
});
}
async post(body: string) { // an RPC method
this.ctx.storage.sql.exec("insert into msgs (body, at) values (?, ?)", body, Date.now());
return this.ctx.storage.sql.exec("select count(*) as n from msgs").one().n;
}
}
export default { async fetch(request, env) {
const stub = env.ROOMS.getByName(new URL(request.url).searchParams.get("room") ?? "lobby");
return Response.json({ count: await stub.post(await request.text()) });
} };
Watch out.
- One instance means one throughput ceiling. Shard by id (per tenant, per room) and never route all traffic to a single well-known name.
- Every class needs a
migrationsentry; renaming or deleting a class without one breaks the deploy or orphans storage. - Calls on the stub are RPC: arguments are cloned, a method detached with
.bind()becomes a remote call named "bind", and stubs held across hibernation are stale.
Related: websockets-in-a-durable-object-with-hibernation · durable-object-alarms-are-per-object-timers · workflows-step-do-is-an-rpc-receiver