A Durable Object is one single-threaded instance per id, with SQLite

idFromName gives the same object worldwide; requests to it serialize, storage is transactional, and new classes must be SQLite-backed, up to 10 GB each.

Durable Objects

· Chapter

55

·

3

min read

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 migrations entry; 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