Durable Objects — Building Stateful Real-time Apps
Build stateful applications with Durable Objects: WebSockets, game servers, coordination — and built-in SQLite storage.
Durable Objects (DO) provide a stateful coordination layer for Cloudflare Workers. Each DO runs as a globally unique instance — retaining state with strong consistency guarantees.
flowchart LR
subgraph EdgeWorkers["Stateless Workers (Global Edge)"]
direction TB
W_Tokyo["Worker Tokyo"]
W_London["Worker London"]
W_US["Worker US"]
end
subgraph DO_Instance["Durable Object (Globally Unique Instance)"]
direction TB
ID["ID: 'user-room-42'"]
subgraph Memory["RAM (In-Memory State)"]
State["Active Connections & State"]
end
subgraph Storage["Persistent Storage"]
SQL[("SQLite / KV Storage")]
end
ID --> Memory
Memory <--> Storage
end
W_Tokyo -->|"All requests with same ID<br/>route to single instance"| DO_Instance
W_London -->|"Strong Consistency<br/>(Zero race conditions)"| DO_Instance
W_US -->|"Continuous state retention"| DO_Instance
Workers vs Durable Objects#
flowchart TB
subgraph Clients["Clients & Browsers"]
C1["User A (WebSocket)"]
C2["User B (WebSocket)"]
C3["User C (HTTP REST)"]
end
subgraph Edge["Cloudflare Global Edge (Stateless Workers)"]
W1["Worker (Edge Location 1)"]
W2["Worker (Edge Location 2)"]
end
subgraph DO_Cluster["Durable Objects (Single Coordinated Instance per ID)"]
subgraph DO1["Chat Room DO (ID: room-123)"]
Mem["In-Memory State & Active WebSockets"]
SQL[("Built-in SQLite / KV Storage")]
Alarm["Alarms / Timers"]
Mem <--> SQL
Mem <--> Alarm
end
subgraph DO2["Chat Room DO (ID: room-456)"]
Mem2["In-Memory State"]
SQL2[("SQLite Storage")]
end
end
C1 <-->|"Edge Connection"| W1
C2 <-->|"Edge Connection"| W2
C3 -->|"Request"| W1
W1 <-->|"Route by ID: room-123"| DO1
W2 <-->|"Route by ID: room-123"| DO1
W1 -.->|"Route by ID: room-456"| DO2
| Workers | Durable Objects |
|---|---|
| Stateless | Stateful |
| Multiple requests - multiple instances | Single unique instance per ID |
| No in-memory state retention | Retains state in RAM + persistent storage |
| Infinite automatic horizontal scaling | Scale via sharding across multiple DOs |
| Distributed across 330+ locations | Single global point of execution (supports migration) |
Chat Room — Basic Example#
sequenceDiagram
autonumber
actor ClientA as User Alice
actor ClientB as User Bob
participant Worker as Stateless Worker Router
participant DO as ChatRoom [Durable Object]
Note over ClientA, DO: WebSocket Connection Initialization
ClientA->>Worker: GET /?room=general&name=Alice (Upgrade: WebSocket)
Worker->>DO: stub.fetch(request) [idFromName("general")]
DO->>DO: server.accept(), save session "Alice"
DO-->>ClientA: 101 Switching Protocols (WebSocket Connected)
DO--)ClientB: Broadcast "Alice joined the chat"
Note over ClientA, DO: Real-time Message Broadcast
ClientA->>DO: WS Message: "Hello room!"
DO->>DO: broadcast("Alice: Hello room!", sender=Alice)
DO--)ClientB: WS Message: "Alice: Hello room!"
import { DurableObject } from 'cloudflare:workers';
interface Env {
CHAT_ROOM: DurableObjectNamespace;
}
export class ChatRoom extends DurableObject {
private sessions = new Map<string, WebSocket>();
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const name = url.searchParams.get('name') || 'anonymous';
// WebSocket upgrade
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept();
this.sessions.set(name, server);
// Broadcast welcome
this.broadcast(`${name} joined the chat`);
server.addEventListener('message', (event) => {
this.broadcast(`${name}: ${event.data}`, server);
});
server.addEventListener('close', () => {
this.sessions.delete(name);
this.broadcast(`${name} left`);
});
return new Response(null, { status: 101, webSocket: client });
}
private broadcast(message: string, sender?: WebSocket) {
const data = JSON.stringify({ message, timestamp: Date.now() });
this.sessions.forEach((ws, name) => {
if (ws !== sender && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
}
}
// Worker — router to DO
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const roomId = url.searchParams.get('room') || 'default';
const id = env.CHAT_ROOM.idFromName(roomId);
const stub = env.CHAT_ROOM.get(id);
return stub.fetch(request);
},
};typescriptSQLite Storage — Built-in Storage Engine#
Each Durable Object comes equipped with its own dedicated SQLite storage:
flowchart TD
Req["Request: GET /increment /get /reset"] --> Match{url.pathname}
Match -->|"/increment"| Inc["storage.get('count')<br/>storage.put('count', count + 1)"]
Match -->|"/get"| Get["storage.get('count')"]
Match -->|"/reset"| Res["storage.put('count', 0)"]
Match -->|Other| Err["404 Not Found"]
Inc --> Resp["Return JSON count"]
Get --> Resp
Res --> Resp
export class Counter extends DurableObject {
private storage: DurableObjectStorage;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.storage = ctx.storage;
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
switch (url.pathname) {
case '/increment':
const count = (await this.storage.get<number>('count')) || 0;
await this.storage.put('count', count + 1);
return Response.json({ count: count + 1 });
case '/get':
const current = (await this.storage.get<number>('count')) || 0;
return Response.json({ count: current });
case '/reset':
await this.storage.put('count', 0);
return Response.json({ count: 0 });
default:
return new Response('Not found', { status: 404 });
}
}
}typescriptSQL Queries With storage.sql#
Execute direct SQL queries inside Durable Objects:
flowchart TD
Req["HTTP Request"] --> Init["Create table if not exists: CREATE TABLE IF NOT EXISTS todos (...)"]
Init --> Router{HTTP Method}
Router -->|"GET"| Q1["SELECT * FROM todos ORDER BY id DESC"]
Router -->|"POST"| Q2["INSERT INTO todos (title) VALUES (?)"]
Router -->|"PUT"| Q3["UPDATE todos SET completed = ? WHERE id = ?"]
Router -->|"DELETE"| Q4["DELETE FROM todos WHERE id = ?"]
Router -->|Other| Q5["405 Method Not Allowed"]
Q1 --> Out1["JSON: List of todos"]
Q2 --> Out2["201 Created: new todo"]
Q3 --> Out3["JSON: { success: true }"]
Q4 --> Out4["JSON: { success: true }"]
export class TodoApp extends DurableObject {
async fetch(request: Request): Promise<Response> {
const sql = this.ctx.storage.sql;
// Create table
sql.exec(`
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER DEFAULT 0
)
`);
const url = new URL(request.url);
// List
if (request.method === 'GET') {
const result = sql.exec('SELECT * FROM todos ORDER BY id DESC');
return Response.json(result.toArray());
}
// Create
if (request.method === 'POST') {
const { title } = await request.json();
const result = sql.exec('INSERT INTO todos (title) VALUES (?)', title);
return Response.json({ id: result.lastRowId, title, completed: false }, { status: 201 });
}
// Update
if (request.method === 'PUT') {
const { id, completed } = await request.json();
sql.exec('UPDATE todos SET completed = ? WHERE id = ?', completed, id);
return Response.json({ success: true });
}
// Delete
if (request.method === 'DELETE') {
const id = url.searchParams.get('id');
sql.exec('DELETE FROM todos WHERE id = ?', id);
return Response.json({ success: true });
}
return new Response('Method not allowed', { status: 405 });
}
}typescriptAlarms — Scheduled Tasks for DO#
sequenceDiagram
autonumber
actor User as User / Client
participant DO as Reminder DO
participant Storage as ctx.storage [Alarm Queue]
participant Webhook as External Webhook
Note over DO, Storage: 1. Schedule Timer / Alarm
User->>DO: GET /?delay=5000
DO->>Storage: setAlarm(futureTimestamp)
DO-->>User: {"message": "Alarm set for 5000ms"}
Note over Storage, Webhook: 2. Execution upon Expiry (Wake up DO)
Storage-->>DO: Trigger alarm() handler
DO->>Webhook: POST https://hooks.example.com/notify { event: 'alarm_fired' }
DO->>Storage: setAlarm(nextHourTimestamp) [Recurring trigger]
export class Reminder extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
const alarm = await ctx.storage.getAlarm();
if (alarm) {
console.log('Recovered alarm:', new Date(alarm).toISOString());
}
});
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const delayMs = parseInt(url.searchParams.get('delay') || '5000');
// Schedule alarm after delayMs
await this.ctx.storage.setAlarm(Date.now() + delayMs);
return Response.json({ message: `Alarm set for ${delayMs}ms` });
}
async alarm() {
// Invoked when the alarm fires
console.log('⏰ Alarm fired!');
// Send notification via WebSocket or external webhook
await fetch('https://hooks.example.com/notify', {
method: 'POST',
body: JSON.stringify({ event: 'alarm_fired', time: Date.now() }),
});
// Re-arm alarm if needed
await this.ctx.storage.setAlarm(Date.now() + 3600000);
}
}typescriptMultiplayer Game Server#
flowchart TD
WS["WebSocket Message Event"] --> EventType{msg.type}
EventType -->|"join"| Join["1. Add player session to Map<br/>2. Initialize Player state: x=0, y=0<br/>3. Broadcast: type: 'players'"]
EventType -->|"move"| Move["1. Update player.x += dx, player.y += dy<br/>2. Broadcast: type: 'move'"]
EventType -->|"shoot"| Shoot["1. Calculate bullet vector & angle<br/>2. Broadcast: type: 'shoot'"]
Close["WebSocket Close Event"] --> Leave["1. Remove player from Map & State<br/>2. Broadcast: type: 'leave'"]
Join --> BroadcastAll["Broadcast updated state to all connected room players"]
Move --> BroadcastAll
Shoot --> BroadcastAll
Leave --> BroadcastAll
interface Player {
id: string;
x: number;
y: number;
score: number;
}
export class GameRoom extends DurableObject {
private players = new Map<string, WebSocket>();
private state: Player[] = [];
async fetch(request: Request): Promise<Response> {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept();
server.addEventListener('message', (event) => {
const msg = JSON.parse(event.data as string);
switch (msg.type) {
case 'join':
this.players.set(msg.playerId, server);
this.state.push({ id: msg.playerId, x: 0, y: 0, score: 0 });
this.broadcast({ type: 'players', data: this.state });
break;
case 'move':
const player = this.state.find(p => p.id === msg.playerId);
if (player) {
player.x += msg.dx;
player.y += msg.dy;
this.broadcast({ type: 'move', playerId: msg.playerId, x: player.x, y: player.y });
}
break;
case 'shoot':
this.broadcast({
type: 'shoot',
playerId: msg.playerId,
x: msg.x,
y: msg.y,
angle: msg.angle,
});
break;
}
});
server.addEventListener('close', () => {
this.state = this.state.filter(p => !this.players.has(p.id));
this.players.delete(msg.playerId);
this.broadcast({ type: 'leave', playerId: msg.playerId });
});
return new Response(null, { status: 101, webSocket: client });
}
private broadcast(message: object) {
const data = JSON.stringify(message);
this.players.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
}
}typescriptMigration — Moving DO Across Releases#
Durable Objects support zero-downtime migrations between classes and schemas:
// wrangler.jsonc
{
"durable_objects": {
"bindings": [
{
"name": "COUNTER",
"class_name": "Counter",
"migration": "new_tag"
}
]
}
}typescriptMigration types:
new_tag— New class tagnew_classes— Add multiple classesrename— Rename existing classtransfer— Transfer data between namespaces
Pattern — Sharding#
A single Durable Object instance lives in one physical location at a time. To scale horizontally, shard keys across multiple DOs:
flowchart LR
Req["Request: userId"] --> Formula["shardId = floor(userId / 1000)"]
Formula --> ShardMap{Shard Router}
ShardMap -->|"userId: 0 - 999"| S0["DO Instance: shard-0"]
ShardMap -->|"userId: 1000 - 1999"| S1["DO Instance: shard-1"]
ShardMap -->|"userId: 2000 - 2999"| S2["DO Instance: shard-2"]
ShardMap -->|"userId: N - N+999"| Sn["DO Instance: shard-N"]
// Shard user data by userId
function getUserStub(userId: number, env: Env): DurableObjectStub {
const shardId = Math.floor(userId / 1000); // 1000 users per shard
const id = env.USER_STORE.idFromName(`shard-${shardId}`);
return env.USER_STORE.get(id);
}
// Handler
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const userId = parseInt(url.searchParams.get('userId') || '0');
const stub = getUserStub(userId, env);
return stub.fetch(request);
},
};typescriptConclusion#
Durable Objects address the state problem in serverless computing:
- Real-time: WebSocket connections paired with globally consistent memory state
- Game servers: Low-latency coordination and synchronization
- Distributed Coordination: Global locks, leader election, rate limiting
- Embedded SQLite: Full relational queries without maintaining external databases
Architecture pattern: Keep request handling stateless on Workers, and delegate stateful coordination to Durable Objects. Shard instances to achieve unbounded scale.