Files
claude-skills/durable-objects/references/rules.md
2026-01-30 03:04:10 +00:00

7.5 KiB

Durable Objects Rules & Best Practices

Design & Sharding

Model Around Coordination Atoms

Create one DO per logical unit needing coordination: chat room, game session, document, user, tenant.

// ✅ Good: One DO per chat room
const stub = env.CHAT_ROOM.getByName(roomId);

// ❌ Bad: Single global DO
const stub = env.CHAT_ROOM.getByName("global"); // Bottleneck!

Parent-Child Relationships

For hierarchical data, create separate child DOs. Parent tracks references, children handle own state.

// Parent: GameServer tracks match references
// Children: GameMatch handles individual match state
async createMatch(name: string): Promise<string> {
  const matchId = crypto.randomUUID();
  this.ctx.storage.sql.exec(
    "INSERT INTO matches (id, name) VALUES (?, ?)",
    matchId, name
  );
  const child = this.env.GAME_MATCH.getByName(matchId);
  await child.init(matchId, name);
  return matchId;
}

Location Hints

Influence DO creation location for latency-sensitive apps:

const id = env.GAME.idFromName(gameId, { locationHint: "wnam" });

Available hints: wnam, enam, sam, weur, eeur, apac, oc, afr, me.

Storage

Configure in wrangler:

{ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyDO"] }] }

SQL API is synchronous:

// Write
this.ctx.storage.sql.exec(
  "INSERT INTO items (name, value) VALUES (?, ?)",
  name, value
);

// Read
const rows = this.ctx.storage.sql.exec<{ id: number; name: string }>(
  "SELECT * FROM items WHERE name = ?", name
).toArray();

// Single row
const row = this.ctx.storage.sql.exec<{ count: number }>(
  "SELECT COUNT(*) as count FROM items"
).one();

Migrations

Use PRAGMA user_version for schema versioning:

constructor(ctx: DurableObjectState, env: Env) {
  super(ctx, env);
  ctx.blockConcurrencyWhile(async () => this.migrate());
}

private async migrate() {
  const version = this.ctx.storage.sql
    .exec<{ user_version: number }>("PRAGMA user_version")
    .one().user_version;

  if (version < 1) {
    this.ctx.storage.sql.exec(`
      CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, data TEXT);
      CREATE INDEX IF NOT EXISTS idx_items_data ON items(data);
      PRAGMA user_version = 1;
    `);
  }
  if (version < 2) {
    this.ctx.storage.sql.exec(`
      ALTER TABLE items ADD COLUMN created_at INTEGER;
      PRAGMA user_version = 2;
    `);
  }
}

State Types

Type Speed Persistence Use Case
Class properties Fastest Lost on eviction Caching, active connections
SQLite storage Fast Durable Primary data
External (R2, D1) Variable Durable, cross-DO Large files, shared data

Rule: Always persist critical state to SQLite first, then update in-memory cache.

Concurrency

Input/Output Gates

Storage operations automatically block other requests (input gates). Responses wait for writes (output gates).

async increment(): Promise<number> {
  // Safe: input gates block interleaving during storage ops
  const val = (await this.ctx.storage.get<number>("count")) ?? 0;
  await this.ctx.storage.put("count", val + 1);
  return val + 1;
}

Write Coalescing

Multiple writes without await between them are batched atomically:

// ✅ Good: All three writes commit atomically
this.ctx.storage.sql.exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromId);
this.ctx.storage.sql.exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toId);
this.ctx.storage.sql.exec("INSERT INTO transfers (from_id, to_id, amount) VALUES (?, ?, ?)", fromId, toId, amount);

// ❌ Bad: await breaks coalescing
await this.ctx.storage.put("key1", val1);
await this.ctx.storage.put("key2", val2); // Separate transaction!

Race Conditions with External I/O

fetch() and other non-storage I/O allows interleaving:

// ⚠️ Race condition possible
async processItem(id: string) {
  const item = await this.ctx.storage.get<Item>(`item:${id}`);
  if (item?.status === "pending") {
    await fetch("https://api.example.com/process"); // Other requests can run here!
    await this.ctx.storage.put(`item:${id}`, { status: "completed" });
  }
}

Solution: Use optimistic locking (version numbers) or transaction().

blockConcurrencyWhile()

Blocks ALL concurrency. Use sparingly - only for initialization:

// ✅ Good: One-time init
constructor(ctx: DurableObjectState, env: Env) {
  super(ctx, env);
  ctx.blockConcurrencyWhile(async () => this.migrate());
}

// ❌ Bad: On every request (kills throughput)
async handleRequest() {
  await this.ctx.blockConcurrencyWhile(async () => {
    // ~5ms = max 200 req/sec
  });
}

Never hold across external I/O (fetch, R2, KV).

RPC Methods

Use RPC (compatibility date >= 2024-04-03) instead of fetch() handler:

export class ChatRoom extends DurableObject<Env> {
  async sendMessage(userId: string, content: string): Promise<Message> {
    // Public methods are RPC endpoints
    const result = this.ctx.storage.sql.exec<{ id: number }>(
      "INSERT INTO messages (user_id, content) VALUES (?, ?) RETURNING id",
      userId, content
    );
    return { id: result.one().id, userId, content };
  }
}

// Caller
const stub = env.CHAT_ROOM.getByName(roomId);
const msg = await stub.sendMessage("user-123", "Hello!"); // Typed!

Explicit init() Method

DOs don't know their own ID. Pass identity explicitly:

async init(entityId: string, metadata: Metadata): Promise<void> {
  await this.ctx.storage.put("entityId", entityId);
  await this.ctx.storage.put("metadata", metadata);
}

Alarms

One alarm per DO. setAlarm() replaces existing.

// Schedule
await this.ctx.storage.setAlarm(Date.now() + 60_000);

// Handler
async alarm(): Promise<void> {
  const tasks = this.ctx.storage.sql.exec<Task>(
    "SELECT * FROM tasks WHERE due_at <= ?", Date.now()
  ).toArray();
  
  for (const task of tasks) {
    await this.processTask(task);
  }
  
  // Reschedule if more work
  const next = this.ctx.storage.sql.exec<{ due_at: number }>(
    "SELECT MIN(due_at) as due_at FROM tasks WHERE due_at > ?", Date.now()
  ).one();
  if (next?.due_at) {
    await this.ctx.storage.setAlarm(next.due_at);
  }
}

// Get/Delete
const alarm = await this.ctx.storage.getAlarm();
await this.ctx.storage.deleteAlarm();

Retry: Alarms auto-retry on failure. Use idempotent handlers.

WebSockets (Hibernation API)

async fetch(request: Request): Promise<Response> {
  const pair = new WebSocketPair();
  this.ctx.acceptWebSocket(pair[1]);
  return new Response(null, { status: 101, webSocket: pair[0] });
}

async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
  const data = JSON.parse(message as string);
  // Handle message
  ws.send(JSON.stringify({ type: "ack" }));
}

async webSocketClose(ws: WebSocket, code: number, reason: string) {
  // Cleanup
}

// Broadcast
getWebSockets().forEach(ws => ws.send(JSON.stringify(payload)));

Error Handling

async safeOperation(): Promise<Result> {
  try {
    return await this.riskyOperation();
  } catch (error) {
    console.error("Operation failed:", error);
    // Log to external service if needed
    throw error; // Re-throw to signal failure to caller
  }
}

Note: Uncaught exceptions may terminate the DO instance. In-memory state is lost, but SQLite storage persists.