Compute
Durable Objects
Durable Objects are the coordination block of the Cloudflare
platform. Where a Worker is stateless and runs anywhere, a
Durable Object is a named address at which a piece of state
lives — globally, exactly once. Every request to the same
name lands on the same instance, in order, with private
transactional storage. The room demo below runs on a single
Durable Object named global-demo-room.
Why Durable Objects feel different
-
Named, single instance.
env.ROOM.idFromName("global-demo-room")always resolves to the exact same Durable Object, wherever in the world the request comes from. No load balancer, no replica set, no consistency model to think about — just one instance per name. - Requests are serialized. Concurrent requests to the same DO run one after the other on the same JavaScript thread. The race window between read and write doesn't exist. You write code as if you're the only writer because you are.
-
Private transactional storage. Each DO
has its own SQLite-backed storage, accessible only from
inside the DO.
ctx.storage.transaction()makes read-modify-write atomic — the put is committed, or the get never happened. - Hibernates when idle. The runtime evicts idle DOs from memory and rehydrates them when a request arrives. A room with thousands of attendees costs nothing while everyone is quietly looking at their cursors.
Try it
Shared room
The surface below is a live room. Move your mouse inside it — every other open tab on this page sees your cursor move in real time, and you see theirs. Click anywhere to drop a momentary ripple. Identity is assigned on connect (no login, no cookie), so each tab is its own visitor.
Open this page in a second browser tab to see two cursors chasing each other. The whole thing is one Durable Object accepting hibernation-managed WebSockets and broadcasting between attached peers when messages arrive.
Connecting to global-demo-room…
How the room DO works
The Worker forwards the WebSocket upgrade to a single named
DO via env.ROOM.idFromName("global-demo-room").
The DO accepts the socket through the Hibernation
API — the runtime keeps track of attached sockets,
so the DO can be evicted from memory while they sit idle and
rehydrated automatically when a message arrives. That's how a
room with thousands of attendees stays cheap without keeping
one always-awake process per room.
// src/durable-objects/room.ts (excerpt)
// The Hibernation API: the runtime tracks each
// socket on our behalf and rehydrates the DO
// only when something happens.
export class Room extends DurableObject<Env> {
override async fetch(request: Request) {
const pair = new WebSocketPair();
const [client, server] = [pair[0], pair[1]];
server.serializeAttachment({
userId: crypto.randomUUID(),
name: pickName(), // "amber-otter"
color: pickColor(), // "#2e5bff"
});
// Hand the socket to the runtime — DO can
// hibernate while it's idle, no cost.
this.ctx.acceptWebSocket(server);
return new Response(null, {
status: 101, webSocket: client,
});
}
override async webSocketMessage(ws, raw) {
const msg = JSON.parse(String(raw));
const { userId } = ws.deserializeAttachment();
// Broadcast: every other socket lives right
// here in memory. No fan-out service needed.
for (const peer of this.ctx.getWebSockets()) {
if (peer === ws) continue;
peer.send(JSON.stringify({ ...msg, userId }));
}
}
}
Try it
Race them: atomic counter vs. KV
Some operations have to land in a known order, exactly once. Order numbers, ticket numbers, idempotency keys, leaderboard updates — anywhere two requests racing each other would create a bug. Try the same operation against two backends side-by-side: a Durable Object on the left, a naïve Worker + KV on the right. Press Claim 1 to see them tick up calmly; then press Stress test ×15 to fire 15 parallel requests at each and watch what happens.
Durable Object
ctx.storage.transaction(...)
// press a button above
Worker + KV (naïve)
get → +1 → put
// press a button above
How the ticket counter works
Two layers of guarantee make the DO version race-free.
First, DOs serialize requests to a single
instance — even without the transaction, two concurrent
claims for this named counter run one after the other on the
same DO. Second, ctx.storage.transaction() makes
this read-modify-write atomic and crash-safe — the put is
committed, or the get never happened.
// src/durable-objects/ticket-counter.ts (excerpt)
// DOs serialize requests to a single instance,
// AND ctx.storage.transaction() makes the read +
// write atomic and crash-safe.
export class TicketCounter extends DurableObject<Env> {
override async fetch(request: Request) {
if (request.method === "POST") {
const next = await this.ctx.storage.transaction(
async (tx) => {
const current = (await tx.get<number>("count")) ?? 0;
const incremented = current + 1;
await tx.put("count", incremented);
return incremented;
},
);
return Response.json({ ticket: next });
}
}
}
For comparison, here's the KV foil that powers the right column. It looks reasonable. It is fine under one user. It falls apart the moment two requests arrive in the same millisecond: duplicates and lost increments are the usual visible failure, and gaps can appear too. KV's eventual consistency makes the window much wider in production than it appears in dev.
// src/handlers/durable-objects.ts (excerpt — DO NOT do this)
// What the SAME counter looks like against KV
// with no synchronization. Two concurrent calls
// both read the same value and both write +1 —
// one increment is silently lost.
async function handleKvRace(_req: Request, env: Env) {
const raw = await env.KV.get("race:counter");
const current = raw == null ? 0 : Number(raw);
// Race window — exposed deliberately for the
// demo. KV's eventual consistency widens this
// gap in production with no help from us.
await new Promise((r) => setTimeout(r, 20));
const incremented = current + 1;
await env.KV.put("race:counter", String(incremented));
return Response.json({ ticket: incremented });
}
When Durable Objects is the right tool
- Real-time multiplayer state: chat rooms, live cursors, game lobbies
- Atomic counters, leaderboards, idempotency keys, sequence numbers
- Per-entity coordinators: one DO per user, document, order, or session
- Single-writer queues, rate limiters, or scheduled work via DO alarms
- Anywhere you'd reach for "a small server with state" — but globally
For read-heavy global config or per-user preferences, prefer KV. For relational queries across many rows, prefer D1. For long-running, durable orchestration with retries and human-in-the-loop steps, look at Workflows. DOs are the right answer when you need a single, named, in-order coordination point with its own state.