Cloudflare Durable Objects Reference Sheet

Just a guy who loves to write code and watch anime.
Search for a command to run...

Just a guy who loves to write code and watch anime.
No comments yet. Be the first to comment.
Qualities that make outstanding builders.

Bipedal vs quadrupedal: how many legs This is about the number of legs the creature walks on. Bipedal. Two legs. Humans, ostriches, T-rex, kangaroos, most fantasy humanoids. Quadrupedal. Four legs. Do

Intro You hear "lerp" and "smoothstep" everywhere in game dev. They sound like math jargon. They're not. Both are small tools that do the same job: smoothly move from one value to another. The problem

What is Kinematics Kinematics is the math field. It's the study of how things move without worrying about forces (which would be dynamics). FK and IK are the two branches: forward kinematics and inver

Intro Textures are usually the biggest cost in a 3D scene. Memory, bandwidth, and load time all get eaten by them. Resizing them is the obvious lever. There's more. This post is about the less obvious

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
{
"durable_objects": {
"bindings": [
{
"name": "CHAT_ROOM",
"class_name": "ChatRoom"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["ChatRoom"]
}
]
}
npx wrangler types # Always run after config changes
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();
}
}
export class GameRoom extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.activePlayers = new Map(); // Lost on hibernation
this.gameStatus = "waiting"; // Lost on hibernation
}
}
// 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");
// 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();
// 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
// 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 };
}
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
}
});
}
}
// 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
}
Active → Processing requests
Idle (hibernatable) → No timers, fetch calls, or standard WebSockets
Hibernated → Asleep, WebSockets stay connected, no billing
Inactive → Completely removed from memory
// ❌ 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
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();
});
}
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
}
}
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");
}
// 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;
}
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 };
}
// 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
}
// 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);
}
},
};
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
DO not receiving requests?
Check wrangler.jsonc bindings
Run npx wrangler types after config changes
Verify 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 memory
Check if table schema is created in constructor
Verify DO has SQLite backend in migrations
Race condition/flickering?
Add shouldRevalidate to prevent loader revalidation
Use WebSocket for real-time, form submission for persistence