Cloudflare Durable Objects — stateful edge compute.
Durable Objects (DOs) are single-instance, globally-unique JavaScript objects with persistent storage and a co-located execution environment. Unlike Workers (which are stateless isolates), each DO instance:
this.ctx.storageRequires Workers Paid plan.
[[durable_objects.bindings]]
name = "CHAT_ROOM"
class_name = "ChatRoom"
# Must also declare the migration
[[migrations]]
tag = "v1"
new_classes = ["ChatRoom"]
import { DurableObject } from 'cloudflare:workers';
export class ChatRoom extends DurableObject {
private sessions: Map<WebSocket, { userId: string }> = new Map();
async fetch(request: Request): Promise<Response> {
const upgradeHeader = request.headers.get('Upgrade');
if (upgradeHeader === 'websocket') {
return this.handleWebSocket(request);
}
return new Response('Expected WebSocket', { status: 426 });
}
private async handleWebSocket(request: Request): Promise<Response> {
const [client, server] = Object.values(new WebSocketPair());
server.addEventListener('message', async (event) => {
const data = JSON.parse(event.data as string);
await this.broadcast(data);
});
server.addEventListener('close', () => {
this.sessions.delete(server);
});
this.ctx.acceptWebSocket(server);
this.sessions.set(server, { userId: new URL(request.url).searchParams.get('userId') ?? 'anon' });
return new Response(null, { status: 101, webSocket: client });
}
private async broadcast(message: unknown): Promise<void> {
const payload = JSON.stringify(message);
for (const ws of this.sessions.keys()) {
try { ws.send(payload); } catch { this.sessions.delete(ws); }
}
}
}
// By name — same name always routes to same instance
const id = env.CHAT_ROOM.idFromName('room:project-123');
const stub = env.CHAT_ROOM.get(id);
const response = await stub.fetch(request);
// By generated ID — unique per creation
const id = env.CHAT_ROOM.newUniqueId();
const stub = env.CHAT_ROOM.get(id);
export class Counter extends DurableObject {
async increment(amount = 1): Promise<number> {
const current = (await this.ctx.storage.get<number>('count')) ?? 0;
const next = current + amount;
await this.ctx.storage.put('count', next);
return next;
}
// Atomic transaction
async transfer(from: string, to: string, amount: number): Promise<void> {
await this.ctx.storage.transaction(async (txn) => {
const fromBalance = (await txn.get<number>(from)) ?? 0;
if (fromBalance < amount) throw new Error('Insufficient funds');
await txn.put(from, fromBalance - amount);
await txn.put(to, ((await txn.get<number>(to)) ?? 0) + amount);
});
}
}
// DO streams Workers AI output to all connected WebSocket clients
async streamAI(prompt: string): Promise<void> {
const stream = await this.env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
messages: [{ role: 'user', content: prompt }],
stream: true,
});
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
this.broadcast({ type: 'chunk', text: new TextDecoder().decode(value) });
}
this.broadcast({ type: 'done' });
}
export class AgentSession extends DurableObject {
async setAlarm(delayMs: number): Promise<void> {
await this.ctx.storage.setAlarm(Date.now() + delayMs);
}
// Called automatically when alarm fires
async alarm(): Promise<void> {
await this.cleanupExpiredSessions();
}
}
[[migrations]] entry in wrangler.toml. Missing this causes a deploy error.ctx.acceptWebSocket() vs new WebSocketPair(): Use ctx.acceptWebSocket() for hibernating WebSockets (cheaper) — the DO can sleep and wake on message receipt.storage.transaction() for atomic multi-key operations.| Need | Use |
|---|---|
| Real-time collaboration (WebSockets) | Durable Objects |
| Atomic counter / rate limiter | Durable Objects |
| Async background jobs | Queues |
| Shared config/flags | KV |
| Per-user session data | KV (with TTL) |
| Structured data + SQL queries | D1 |