Serverless functions are easy to scale because they forget everything between requests. That is also why they are bad at coordination. If two users edit the same document, two players join the same match, or a thousand requests share one rate limit, something has to be the single place where those requests meet and agree. Normally that place is a database with locks, or a Redis instance in one region, and every Worker anywhere in the world pays a round trip to reach it.

Cloudflare Durable Objects take a different approach. A Durable Object is an instance of a JavaScript class with a globally unique name. Cloudflare guarantees that at most one instance with a given name is running at any moment, routes every request for that name to it, runs its code one event at a time, and gives it private, strongly consistent storage on the same machine. It is the actor model run as a managed service. This article explains the model from first principles, then works through a chat room, the storage and alarm APIs, the limits, and the ways it fails. API names and limits were checked against Cloudflare's documentation on 2026-10-01; check the docs for your compatibility date before relying on a detail. For the wider picture of running code in PoPs, see cloud edge compute architecture.

Advertisement

The mental model: one name, one instance, one thread

Three properties define a Durable Object, and every design decision follows from them.

Uniqueness. Each object has an ID. Every request carrying that ID, from any Worker in any city, lands on the same live instance. You never run a consensus protocol to decide who owns a room, because the platform has already decided.

Serial execution. An instance runs on a single thread. Two requests never execute JavaScript at the same time inside one object. That removes the need for locks in your code, but it also caps throughput: Cloudflare documents a soft limit of about 1,000 requests per second for an individual object.

Co-located storage. Each object has its own private storage, and new classes use an embedded SQLite database of up to 10 GB per object. Storage calls are local, not network round trips, which is why per-request logic can afford to read and write state every time.

The consequence is a rule of granularity: model one object per thing that needs coordination, such as one per document, room, user, tenant or counter. Do not build one global object for everything. A million small objects scale out; one big object is a single-threaded bottleneck. If you know sharding, a Durable Object ID is a shard key whose shard the platform places and moves for you.

Many stateless Workers, one addressable stateful object per nameClient ATokyoClient BFrankfurtClient COhioWorkernearest PoPWorkernearest PoPWorkernearest PoPDurable Objectid = idFromName(room)stub.join()stub.post()stub.post()Input gateone event at a timeSQLite storageup to 10 GBOutput gateheld until durableAlarmone per objectalarm()Hibernated socketssurvive evictionEvery request for the same name reaches the same single-threaded instance, wherever it runs.Storage sits next to that instance, so reads and writes are local calls, not network hops.Names as documented on developers.cloudflare.com, checked 2026-10-01
Workers are stateless and run near each client. They obtain a stub for a named object and call it; the object processes events one at a time behind input and output gates, with local SQLite storage, a single alarm and hibernatable WebSockets.

Addressing: IDs, stubs and placement

Your Worker reaches objects through a namespace binding declared in the Wrangler configuration. The namespace offers idFromName(name), which deterministically derives an ID from a string, newUniqueId() for a random ID you must store somewhere, and idFromString(s) to rebuild an ID you serialised earlier. get(id) returns a stub, and getByName(name) combines naming and lookup. Calling methods on the stub is a remote procedure call to wherever the object lives.

An object is created near the first request that touches it. You can pass a locationHint such as weur or enam, but hints are best effort. A jurisdiction is different: env.ROOMS.jurisdiction("eu") returns a sub-namespace whose objects only run and store data inside that jurisdiction. Documented values are eu, us and fedramp. Use jurisdictions for data residency, and hints only for latency.

Placement is sticky: an object created in Frankfurt stays near Frankfurt, and users in Sydney pay the trip. Key objects by something with locality, such as a team or a match.

// wrangler.jsonc (migration format; newer docs also describe an "exports" form)
{
  "durable_objects": { "bindings": [ { "name": "ROOMS", "class_name": "ChatRoom" } ] },
  "migrations": [ { "tag": "v1", "new_sqlite_classes": ["ChatRoom"] } ]
}

// worker.ts
export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const room = new URL(req.url).searchParams.get("room") ?? "lobby";
    const stub = env.ROOMS.getByName(room);          // same name -> same instance
    if (req.headers.get("Upgrade") === "websocket") return stub.fetch(req);
    return Response.json(await stub.recent(50));     // RPC method on the class
  },
};
Advertisement

Concurrency without locks: input and output gates

Single-threaded does not mean free of races. JavaScript yields at every await, and a second request can run while the first is waiting. Durable Objects add two mechanisms that make the common case safe.

The input gate closes while the object waits on its own storage. While a storage read or write is in flight, no new events are delivered, except the storage completion itself. A sequence such as read counter, add one, write counter cannot be interleaved by another request, even though it contains awaits.

The output gate holds back outgoing messages, such as responses, fetches and WebSocket sends, until the writes made before them are durable. You can respond without awaiting the write. If the write fails, the held messages are replaced with errors and the object restarts. No client is ever told that something succeeded when it did not persist.

The gates do not cover awaits on anything other than storage. If your handler calls await fetch(...) to an external API, the input gate opens and other requests run in the meantime. State you read before the fetch may be stale afterwards. You can either re-read after external I/O, or use ctx.blockConcurrencyWhile(fn), which blocks all other events until fn finishes. It is designed for constructor-time initialisation. The callback has a 30-second timeout, and exceeding it resets the object, so never wrap slow network calls in it.

Storage: SQLite in the object

A SQLite-backed object exposes ctx.storage.sql.exec(query, ...bindings), which returns a cursor with toArray(), one(), raw() and the billing counters rowsRead and rowsWritten. The calls are synchronous, which is possible because the database is local. ctx.storage.transactionSync(fn) wraps a block in a transaction, and ctx.storage.kv gives a synchronous key-value view for small values.

Documented limits include 10 GB per object, 2 MB per row or key-value pair, 100 columns per table, 100 bound parameters per statement and 100 KB per statement. Point-in-time recovery keeps a 30-day window: getBookmarkForTime(t) returns a bookmark, and onNextSessionRestoreBookmark(b) restores the object to it on the next restart. That is your undo button for a bad deploy that corrupted one object.

The storage backend is fixed when a class is provisioned. Older classes created with the key-value backend cannot be switched to SQLite in place. Cloudflare says a migration path is planned, so new classes should start on SQLite.

Worked example: a chat room that hibernates

A chat room needs exactly what an object provides: one ordered history per room, fan-out to connected sockets, and no global database. The class below stores messages in SQLite and accepts WebSockets through the Hibernation API, so an idle room costs no duration while sockets stay open.

import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {          // schema before any event
      ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS msg(
        id INTEGER PRIMARY KEY AUTOINCREMENT, user TEXT, body TEXT, ts INTEGER)`);
    });
  }

  async fetch(req: Request): Promise<Response> {
    const pair = new WebSocketPair();
    const user = new URL(req.url).searchParams.get("user") ?? "anon";
    this.ctx.acceptWebSocket(pair[1], [user]);       // hibernatable, tagged
    pair[1].serializeAttachment({ user });           // survives hibernation
    return new Response(null, { status: 101, webSocket: pair[0] });
  }

  async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer) {
    const { user } = ws.deserializeAttachment();
    const body = String(data).slice(0, 2000);
    this.ctx.storage.sql.exec("INSERT INTO msg(user, body, ts) VALUES (?, ?, ?)",
                              user, body, Date.now());
    const out = JSON.stringify({ user, body });
    for (const s of this.ctx.getWebSockets()) s.send(out);   // held by output gate
  }

  async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {
    ws.close(code, reason);
  }

  async recent(n: number) {
    return this.ctx.storage.sql
      .exec("SELECT user, body, ts FROM msg ORDER BY id DESC LIMIT ?", n).toArray();
  }
}

Trace one message. Alice's socket sends text, and the runtime wakes the object if it was hibernated. It reconstructs the class by running the constructor again, then calls webSocketMessage. The insert is a local SQLite write. The broadcast sends are queued behind the output gate until the write is durable, so no client sees a message the room could later lose. Bob's message, arriving in the same millisecond, waits for its turn. Ordering in the table is therefore the single order every client sees.

Two details matter. In-memory fields are discarded on hibernation, so per-connection data lives in the socket attachment, which is limited to 16,384 bytes, and room data lives in SQLite. And setWebSocketAutoResponse can answer heartbeat pings without waking the object at all, which keeps idle rooms cheap.

Alarms: timers that survive restarts

An object can schedule its own future work with ctx.storage.setAlarm(epochMs). When the time comes, the runtime calls alarm(alarmInfo). Each object has one alarm, and setting a new one replaces the old. Delivery is at least once. If the handler throws, it is retried with exponential backoff starting at 2 seconds, for up to six retries, and alarmInfo.retryCount tells you which attempt this is.

Two patterns follow. First, store a queue of due tasks in SQLite and point the single alarm at the earliest one; in the handler, process everything due and reschedule for the next. Second, make the handler idempotent, because at-least-once means a task can run twice; record completion so a retry skips finished tasks.

async alarm(info?: { retryCount: number; isRetry: boolean }) {
  const now = Date.now();
  const due = this.ctx.storage.sql
    .exec("SELECT id, kind FROM task WHERE run_at <= ? AND done = 0", now).toArray();
  for (const t of due) {
    await this.runTask(t);                            // must be idempotent
    this.ctx.storage.sql.exec("UPDATE task SET done = 1 WHERE id = ?", t.id);
  }
  const next = this.ctx.storage.sql
    .exec("SELECT MIN(run_at) AS t FROM task WHERE done = 0").one().t;
  if (next !== null) await this.ctx.storage.setAlarm(next);
}

Lifecycle and cost

An object that has no pending work can hibernate after about ten seconds of inactivity, provided nothing holds it awake. Timers, outbound WebSockets and pending I/O all keep it resident. Duration is billed while the object is resident in memory, so a design that keeps one long-lived outbound connection per object pays for wall-clock time around the clock. Hibernated inbound WebSockets do not accrue duration. Default CPU time per request is 30 seconds and can be raised to five minutes with cpu_ms.

The constructor runs again after every eviction or hibernation, so keep it cheap and idempotent. Compare this cost model with general serverless platforms, where state lives elsewhere.

Failure modes and how to handle them

FailureWhat you seeWhat to do
Hot objectLatency climbs and errors carry .overloaded near the ~1,000 req/s soft limitSplit the key, for example a counter sharded into N objects summed on read; do not retry overloaded errors
Transient infrastructure errorException with .retryable trueRetry idempotent calls with jittered exponential backoff, using a new stub each attempt, because a stub that threw may be broken
User code threw.remote trueTreat it as a bug in your class, not a platform error; log and alert
Write failed after responseClient receives an error instead of the held response; object restartsNothing to undo; the output gate already prevented a false success
Initialisation too slowObject reset after 30 s in blockConcurrencyWhileMove network calls out of it; lazily load instead
Alarm ran twiceDuplicate side effectsIdempotency keys and completion flags in the same transaction
State read before an external fetch went staleLost updates despite single threadRe-read after the await, or use a version column and compare before writing

The stale-read row surprises experienced developers: the gates serialise storage access, not whole handlers.

When to use Durable Objects, and when not to

Use them for coordination per entity: collaborative documents, chat and presence, multiplayer game state, per-user or per-tenant rate limiting, booking without double-sell, and WebSocket fan-out. They are also a good fit for per-tenant databases, where each customer's data is one SQLite object with its own size budget and recovery window. The real-time collaboration design on this site describes the same one-authority-per-document shape built on servers.

Do not use them for analytics across all entities, because there is no query across objects; you fan out yourself or replicate to a warehouse. Avoid them for globally read-heavy data that tolerates staleness, where a cache or key-value store is cheaper and closer to readers, and for workloads that need one object to sustain far above the per-object soft limit. You gain strong consistency per key and give up cross-key transactions; atomic updates across two objects need a saga or a coordinator object.

What to do next

  1. List the entities in your system that need serialised updates and pick the object key for each; reject any design with one global object.
  2. Start new classes on the SQLite backend with new_sqlite_classes; the backend cannot be changed later.
  3. Audit every handler for an external await between a read and a write; add a re-read or a version check.
  4. Make alarm handlers idempotent and drive them from a task table rather than in-memory timers.
  5. Use the WebSocket Hibernation API for inbound sockets, keep per-socket data in attachments, and answer heartbeats with auto-responses.
  6. Wrap stub calls in a retry helper that honours .retryable, refuses .overloaded and recreates the stub.
  7. Load-test one hot key to find your real per-object ceiling, and design the sharded fallback before launch.
  8. Rehearse a point-in-time restore on a staging object so the recovery path is proven.
Key takeaway: A Durable Object is a named, single-threaded actor with its own SQLite database, placed and kept unique by Cloudflare. Input and output gates make storage read-modify-write safe and prevent false acknowledgements, but external awaits still allow interleaving. Key objects by the entity that needs coordination, keep each one well below its throughput ceiling, make alarms idempotent, hibernate inbound sockets, and treat cross-object work as a distributed protocol rather than a transaction.