Cloudflare Durable Objects Reference Sheet

Just a guy who loves to write code and watch anime.
1. Core Concept & Mental Model
Durable Objects = Stateful Actors
Each DO instance has a globally unique ID
One instance per ID anywhere in the world
Combines compute + storage in one place
Single-threaded (no race conditions!)
// Basic pattern
const id = env.MY_DO.idFromName("room-123"); // Same ID globally
const stub = env.MY_DO.get(id); // Get client to talk to DO
const result = await stub.myMethod(); // Call method on DO
2. Project Setup
wrangler.jsonc Configuration
{
"durable_objects": {
"bindings": [
{
"name": "CHAT_ROOM",
"class_name": "ChatRoom"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["ChatRoom"]
}
]
}
Generate Types
npx wrangler types # Always run after config changes
3. Basic DO Class Structure
import { DurableObject } from "cloudflare:workers";
export class ChatRoom extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// Initialize schema on startup
this.ctx.blockConcurrencyWhile(async () => {
await this.initializeSchema();
});
}
private async initializeSchema() {
await this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
timestamp INTEGER NOT NULL
);
`);
}
// RPC methods - called from Workers
async getMessage(id: string) {
return this.ctx.storage.sql
.exec("SELECT * FROM messages WHERE id = ?", id)
.one();
}
}
4. Storage Options (3 Types)
In-Memory (Temporary, Fast)
export class GameRoom extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.activePlayers = new Map(); // Lost on hibernation
this.gameStatus = "waiting"; // Lost on hibernation
}
}
Key-Value Storage (Persistent)
// Store simple data
await this.ctx.storage.put("gameSettings", { difficulty: "hard" });
await this.ctx.storage.put("playerCount", 5);
// Retrieve data
const settings = await this.ctx.storage.get("gameSettings");
const count = await this.ctx.storage.get("playerCount");
SQLite Storage (Persistent, Queryable)
// Insert
await this.ctx.storage.sql.exec(
"INSERT INTO messages (id, content, timestamp) VALUES (?, ?, ?)",
messageId,
content,
Date.now()
);
// Query with types
const messages =
this.ctx.storage.sql.exec <
Message >
("SELECT * FROM messages ORDER BY timestamp DESC LIMIT ?", 10).toArray();
// Single result
const message = this.ctx.storage.sql
.exec("SELECT * FROM messages WHERE id = ?", id)
.one();
5. Calling DOs from Workers
idFromName vs newUniqueId
// idFromName - Same string = same DO (perfect for rooms)
const roomId = env.CHAT_ROOM.idFromName("lobby-1"); // Always same DO
// newUniqueId - Random ID (better performance, store ID somewhere)
const sessionId = env.USER_SESSION.newUniqueId(); // Random unique DO
RPC Method Calls
// In your Worker/React Router
export async function loader({ context, params }) {
const id = context.cloudflare.env.CHAT_ROOM.idFromName(params.roomId);
const room = context.cloudflare.env.CHAT_ROOM.get(id);
// Call DO methods directly
const messages = await room.getMessages(50);
const stats = await room.getRoomStats();
return { messages, stats };
}
6. WebSocket Integration
DO WebSocket Handler
export class ChatRoom extends DurableObject {
// Accept WebSocket connections
async fetch(request: Request): Promise<Response> {
const webSocketPair = new WebSocketPair();
const [client, server] = Object.values(webSocketPair);
// Use hibernation-friendly API
this.ctx.acceptWebSocket(server);
return new Response(null, {
status: 101,
webSocket: client, // Return to browser
});
}
// Handle messages from browser
async webSocketMessage(ws: WebSocket, message: string) {
const data = JSON.parse(message);
if (data.type === "join_room") {
// Send recent messages to this user
const messages = await this.getMessages(20);
ws.send(JSON.stringify({ type: "room_joined", messages }));
}
}
// Broadcast to all connected users
async sendMessage(content: string) {
// Save to database first
await this.ctx.storage.sql.exec("INSERT INTO messages...", content);
// Then broadcast to all WebSockets
const sockets = this.ctx.getWebSockets();
const messageData = JSON.stringify({ type: "new_message", content });
sockets.forEach((socket) => {
try {
socket.send(messageData);
} catch (error) {
// Socket closed, ignore
}
});
}
}
Worker WebSocket Endpoint
// routes/api/rooms/[roomId]/ws.ts
export async function loader({ context, params }) {
const id = context.cloudflare.env.CHAT_ROOM.idFromName(params.roomId);
const room = context.cloudflare.env.CHAT_ROOM.get(id);
return room.fetch(request); // Forward to DO
}
7. DO Lifecycle & Hibernation
Lifecycle States
Active → Processing requests
Idle (hibernatable) → No timers, fetch calls, or standard WebSockets
Hibernated → Asleep, WebSockets stay connected, no billing
Inactive → Completely removed from memory
Hibernation Rules
// ❌ Prevents hibernation
setTimeout(() => {}, 1000); // Timer active
fetch("https://api.example.com"); // Fetch pending
server.addEventListener("message", handler); // Standard WebSocket
// ✅ Allows hibernation
this.ctx.acceptWebSocket(server); // Hibernation WebSocket API
// No timers, no pending fetches
blockConcurrencyWhile Pattern
constructor(ctx, env) {
super(ctx, env);
// Prevent requests until initialization is done
this.ctx.blockConcurrencyWhile(async () => {
// Load persistent state into memory
this.gameState = await this.ctx.storage.get("gameState") || {};
await this.initializeSchema();
});
}
8. Error Handling & Retries
Error Properties
try {
const room = env.CHAT_ROOM.get(id);
await room.sendMessage(content);
} catch (error) {
if (error.retryable) {
// Safe to retry with backoff
}
if (error.overloaded) {
// DON'T retry - will make it worse
}
if (error.remote) {
// Error from DO code or infrastructure
}
}
Retry Pattern
async function callDOWithRetry(stub, method, args, maxAttempts = 3) {
let attempt = 0;
while (attempt < maxAttempts) {
try {
return await stub[method](...args);
} catch (error) {
if (!error.retryable || error.overloaded) throw error;
const backoff = 100 * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, backoff));
attempt++;
}
}
throw new Error("Max retries exceeded");
}
9. React Router 7 Integration
Preventing Race Conditions
// In your route file
export function shouldRevalidate({ formData, defaultShouldRevalidate }) {
const intent = formData?.get("intent");
// Don't revalidate for real-time actions
if (intent === "sendMessage") {
return false; // WebSocket handles updates
}
return defaultShouldRevalidate;
}
Loader + Action Pattern
export async function loader({ context, params }) {
const id = context.cloudflare.env.CHAT_ROOM.idFromName(params.roomId);
const room = context.cloudflare.env.CHAT_ROOM.get(id);
return await room.getMessages();
}
export async function action({ context, params, request }) {
const formData = await request.formData();
const content = formData.get("content");
const id = context.cloudflare.env.CHAT_ROOM.idFromName(params.roomId);
const room = context.cloudflare.env.CHAT_ROOM.get(id);
await room.sendMessage(content);
return { success: true };
}
10. Common Patterns
Multiple DO Classes
// Different concerns = different DO classes
export class ChatRoom extends DurableObject {
// Handles chat for one room
}
export class UserSession extends DurableObject {
// Handles user presence/authentication
}
export class GameRoom extends DurableObject {
// Handles game state for one match
}
Room Management Pattern
// Worker routes to specific rooms
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname.startsWith("/room/")) {
const roomId = url.pathname.split("/")[2];
const id = env.CHAT_ROOM.idFromName(roomId);
const room = env.CHAT_ROOM.get(id);
return room.handleRequest(request);
}
},
};
Data Architecture Split
D1 Database (Global):
├── users (accounts, profiles)
├── rooms (metadata, descriptions)
└── global_stats (leaderboards)
DO Storage (Per Instance):
├── messages (chat history)
├── active_users (live connections)
└── room_state (game state, etc.)
DO Memory (Temporary):
├── websocket_connections
├── pending_actions
└── cache_data
11. Quick Troubleshooting
DO not receiving requests?
Check wrangler.jsonc bindings
Run
npx wrangler typesafter config changesVerify migration is applied
WebSocket not connecting?
Check WSS vs WS protocol (HTTPS needs WSS)
Verify WebSocket endpoint forwards to DO
Look for hibernation-breaking code
Messages not persisting?
Ensure using
this.ctx.storage.sql.exec()not just memoryCheck if table schema is created in constructor
Verify DO has SQLite backend in migrations
Race condition/flickering?
Add
shouldRevalidateto prevent loader revalidationUse WebSocket for real-time, form submission for persistence






