Skip to main content

Command Palette

Search for a command to run...

Cloudflare Durable Objects Reference Sheet

Updated
6 min read
Cloudflare Durable Objects Reference Sheet
T

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 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