Durable Objects cho realtime: chat, collab, game state

Durable Object là single-writer coordination của Cloudflare: 1 roomId = 1 instance, WebSocket hibernation, storage persistent. 6 pattern, API cốt lõi, và khi nào DO là quá mức.

· 8 phút đọc · Read in English
6 pattern Durable Objects cho realtime: chat room, collab editor CRDT, game tick, presence, rate limiter, single-flight — kèm WebSocket Hibernation, storage SQL beta và alarm scheduler

TL;DR

Durable Object (DO) là primitive duy nhất trong nền tảng Workers có đảm bảo single-writer cho một ID. Mỗi DO instance chạy ở 1 PoP, giữ state trong bộ nhớ + storage bền vững. Tự động chuyển đi khi PoP fail.

Luận điểm chính:

DO không phải thay thế cho database. Nó là tầng điều phối state. Dùng đúng cho: chat room (1 room = 1 DO), collab editor (1 doc = 1 DO), game match (1 match = 1 DO), rate limiter per-user. Dùng sai (CRUD thông thường, lưu toàn bộ bảng user) = nghẽn cổ chai khi scale.

Bài này đi qua: mô hình tư duy DO, WebSocket Hibernation API, 6 pattern thực tế với code, lựa chọn storage (KV vs SQL beta), migration + triển khai, alarm cho công việc theo lịch, và khi nào DO là quá mức cần thiết.

Part 8 đã cover Queue + DO nhanh. Bài này đi sâu vào mặt realtime của DO.


Dành cho ai

  • Dev cần xây dựng tính năng chat, collab, game, presence.
  • Ai đang dùng Pusher/Ably/Socket.io muốn tự host trên edge.
  • Team cần rate limiter có state chính xác (không eventual consistency).

Nên đọc trước: Part 2 (môi trường chạy), Part 8 (Queues + DO cơ bản).

Sau bài này bạn sẽ:

  • Xây 1 chat room WebSocket với DO trong < 50 dòng code.
  • Hiểu Hibernation API để tiết kiệm chi phí 10 lần.
  • Biết khi nào state DO phù hợp, khi nào cần database ngoài.
  • Triển khai CRDT collab editor cơ bản.

Bài này không nói về gì

  • Triển khai CRDT đầy đủ: Yjs / Automerge nội tại phức tạp, bài không đào sâu. Tập trung cách dùng DO làm relay + snapshot.
  • WebRTC: peer-to-peer video/audio, DO có thể làm signaling nhưng tầng media khác.
  • Nhân bản đa region: DO là instance single-region. Nhân bản cần pattern thiết kế riêng.

Mô hình tư duy DO

Kiến trúc DO realtime: Client A/B/C connect WebSocket qua Worker, Worker lấy DO ID theo roomId (env.ROOM.idFromName) và forward request. Single DO instance giữ Set sessions, messages log. Broadcast gửi về tất cả clients. Hibernation API cho idle connection không charge compute. Storage persistent + SQL beta.

3 đặc trưng cốt lõi:

1. Single-writer per ID

1 ID = 1 instance tại 1 thời điểm. Không race, không lock. Worker định tuyến tới đúng instance theo ID.

// Worker
const id = env.ROOM.idFromName("general");  // hash ID → nhất quán
const stub = env.ROOM.get(id);
return stub.fetch(request);  // chuyển tiếp tới DO

Cả 1000 client kết nối "general" cùng lúc → tất cả được định tuyến tới cùng 1 DO instance. DO xử lý tuần tự (event loop) → không cần lock ở tầng ứng dụng.

2. State trong bộ nhớ + storage bền vững

DO giữ state trong bộ nhớ (nhanh). storage.put/get ghi bền vững vào đĩa (Cloudflare quản lý). Khi DO khởi động lại (triển khai, hibernation, PoP chuyển) → state trong bộ nhớ mất, storage còn.

export class RoomDO {
  sessions: Set<WebSocket> = new Set();  // memory only
  
  constructor(readonly state: DurableObjectState) {}
  
  async initialize() {
    // Tải từ storage nếu cần
    const history = await this.state.storage.get<Msg[]>("history") ?? [];
  }
}

3. Vị trí dính

DO instance chạy ở PoP gần request đầu tiên. Sau đó các request tới cùng ID được định tuyến về PoP đó. Nếu PoP fail, DO chuyển sang PoP khác (tự động).

Hệ quả: nếu user ở VN tạo room, DO ở Singapore. User Singapore cùng room = nhanh. User US cùng room = thêm ~200ms RTT cho mỗi message. Với app toàn cầu, có thể cần shard room theo region.


Setup DO đầu tiên

wrangler.jsonc

{
  "name": "my-chat",
  "main": "src/index.ts",
  "compatibility_date": "2026-05-01",
  "durable_objects": {
    "bindings": [
      { "name": "ROOM", "class_name": "RoomDO" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["RoomDO"] }
  ]
}

new_sqlite_classes — dùng storage backend SQLite (mới, khuyến nghị). new_classes = backend KV (cũ).

src/index.ts

export { RoomDO } from "./room";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const roomId = url.pathname.split("/")[2];  // /room/general/ws

    if (!roomId) return new Response("Missing roomId", { status: 400 });

    const id = env.ROOM.idFromName(roomId);
    const stub = env.ROOM.get(id);
    return stub.fetch(request);
  },
} satisfies ExportedHandler<Env>;

src/room.ts

export class RoomDO {
  sessions: Set<WebSocket> = new Set();

  constructor(readonly state: DurableObjectState, readonly env: Env) {}

  async fetch(request: Request): Promise<Response> {
    const pair = new WebSocketPair();
    const [client, server] = [pair[0], pair[1]];

    await this.handleSession(server);

    return new Response(null, { status: 101, webSocket: client });
  }

  async handleSession(ws: WebSocket) {
    ws.accept();
    this.sessions.add(ws);

    ws.addEventListener("message", (msg) => {
      this.broadcast(msg.data as string);
    });

    ws.addEventListener("close", () => {
      this.sessions.delete(ws);
    });
  }

  broadcast(message: string) {
    for (const session of this.sessions) {
      try {
        session.send(message);
      } catch {
        this.sessions.delete(session);
      }
    }
  }
}

Triển khai:

npx wrangler deploy

Kết nối từ trình duyệt:

const ws = new WebSocket("wss://my-chat.<subdomain>.workers.dev/room/general/ws");
ws.onmessage = (e) => console.log(e.data);
ws.send("hello");

< 50 dòng code, chat room đầy đủ. N client kết nối “general” đều nhận tin nhắn nhau.


WebSocket Hibernation API

Vấn đề với WebSocket tiêu chuẩn: mỗi kết nối đang hoạt động giữ 1 DO instance chạy liên tục, tính phí compute mỗi giây. 100 user nhàn rỗi = 100 DO chạy.

Hibernation: DO “ngủ” khi không có sự kiện, thức khi có message. Kết nối nhàn rỗi không tính phí compute.

Cách dùng

Thay ws.accept() + addEventListener bằng:

async fetch(request: Request): Promise<Response> {
  const pair = new WebSocketPair();
  const [client, server] = [pair[0], pair[1]];

  // Thay ws.accept():
  this.state.acceptWebSocket(server, ["user:123", "room:general"]);
  // tag cho phép DO tìm kết nối sau khi thức

  return new Response(null, { status: 101, webSocket: client });
}

// Handler ở mức class thay vì closure
async webSocketMessage(ws: WebSocket, message: string) {
  this.broadcast(message);
}

async webSocketClose(ws: WebSocket, code: number, reason: string) {
  // dọn dẹp
}

async webSocketError(ws: WebSocket, error: unknown) {
  // log
}

Phát sóng dùng getWebSockets():

broadcast(message: string) {
  const sockets = this.state.getWebSockets();  // gồm cả ngủ đông
  for (const ws of sockets) {
    try {
      ws.send(message);
    } catch {
      // socket đã đóng
    }
  }
}

Đánh đổi

  • Ưu: 100 kết nối nhàn rỗi chỉ tính phí storage (~$0.20/GB/tháng), không tính phí compute.
  • Nhược: Handler là class method, không capture closure. State phải tải từ this.

Dùng Hibernation cho 99% trường hợp sử dụng. WebSocket tiêu chuẩn chỉ khi cần truy cập biến private trong closure (hiếm).


Pattern 1: chat room

6 pattern realtime: Chat (broadcast), Collab editor (CRDT + snapshot), Game state (authoritative tick), Presence (heartbeat + expire), Rate limiter (per-user counter), Single-flight (dedupe expensive call).

Triển khai đầy đủ với Hibernation + storage:

interface Msg {
  id: string;
  user: string;
  text: string;
  ts: number;
}

export class RoomDO {
  constructor(readonly state: DurableObjectState, readonly env: Env) {}

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname.endsWith("/ws")) {
      // WebSocket upgrade
      const pair = new WebSocketPair();
      const [client, server] = [pair[0], pair[1]];

      const user = url.searchParams.get("user") ?? "anon";
      this.state.acceptWebSocket(server, [`user:${user}`]);

      // Gửi lịch sử
      const history = await this.state.storage.get<Msg[]>("history") ?? [];
      server.send(JSON.stringify({ type: "history", messages: history }));

      return new Response(null, { status: 101, webSocket: client });
    }

    return new Response("Not found", { status: 404 });
  }

  async webSocketMessage(ws: WebSocket, raw: string) {
    const { text } = JSON.parse(raw);
    const tags = this.state.getTags(ws);
    const user = tags.find((t) => t.startsWith("user:"))?.slice(5) ?? "anon";

    const msg: Msg = {
      id: crypto.randomUUID(),
      user,
      text,
      ts: Date.now(),
    };

    // Lưu bền vững (giữ 100 gần nhất)
    const history = await this.state.storage.get<Msg[]>("history") ?? [];
    history.push(msg);
    if (history.length > 100) history.splice(0, history.length - 100);
    await this.state.storage.put("history", history);

    // Phát sóng
    const payload = JSON.stringify({ type: "message", message: msg });
    for (const socket of this.state.getWebSockets()) {
      try {
        socket.send(payload);
      } catch {}
    }
  }

  async webSocketClose(ws: WebSocket) {
    // Hibernation tự quản lý, không cần dọn dẹp thủ công
  }
}

Pattern 2: collab editor (Yjs)

Yjs là thư viện CRDT phổ biến. DO làm relay + snapshot server.

import * as Y from "yjs";

export class DocDO {
  ydoc: Y.Doc;
  snapshotTimer: number | null = null;

  constructor(readonly state: DurableObjectState) {
    this.ydoc = new Y.Doc();
    state.blockConcurrencyWhile(async () => {
      // Tải snapshot từ storage
      const snapshot = await state.storage.get<Uint8Array>("snapshot");
      if (snapshot) {
        Y.applyUpdate(this.ydoc, snapshot);
      }
    });
  }

  async fetch(request: Request): Promise<Response> {
    const pair = new WebSocketPair();
    const [client, server] = [pair[0], pair[1]];

    this.state.acceptWebSocket(server);

    // Gửi state hiện tại
    const state = Y.encodeStateAsUpdate(this.ydoc);
    server.send(state);

    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws: WebSocket, data: ArrayBuffer) {
    const update = new Uint8Array(data);
    Y.applyUpdate(this.ydoc, update);

    // Phát sóng tới các client khác
    for (const socket of this.state.getWebSockets()) {
      if (socket !== ws) {
        try {
          socket.send(update);
        } catch {}
      }
    }

    // Lên lịch snapshot (debounce)
    this.scheduleSnapshot();
  }

  scheduleSnapshot() {
    if (this.snapshotTimer) return;
    this.snapshotTimer = setTimeout(async () => {
      const snapshot = Y.encodeStateAsUpdate(this.ydoc);
      await this.state.storage.put("snapshot", snapshot);
      this.snapshotTimer = null;
    }, 5000) as unknown as number;
  }
}

Tính chất CRDT: phép merge không xung đột, thứ tự không quan trọng. DO giữ phiên bản chính thức, phát sóng cập nhật tới mọi client.

Snapshot debounced để không ghi storage quá nhiều.


Pattern 3: game state (server có thẩm quyền)

Game cần tick đều đặn. DO alarm cho phép lên lịch công việc tương lai.

export class GameDO {
  state: GameState = { players: {}, ballPos: { x: 0, y: 0 } };

  constructor(readonly doState: DurableObjectState) {
    doState.blockConcurrencyWhile(async () => {
      const saved = await doState.storage.get<GameState>("state");
      if (saved) this.state = saved;
    });
  }

  async fetch(request: Request): Promise<Response> {
    const pair = new WebSocketPair();
    const [client, server] = [pair[0], pair[1]];

    const userId = new URL(request.url).searchParams.get("userId")!;
    this.doState.acceptWebSocket(server, [`player:${userId}`]);

    // Khởi động vòng lặp tick (alarm)
    const alarm = await this.doState.storage.getAlarm();
    if (!alarm) {
      await this.doState.storage.setAlarm(Date.now() + 16);  // 60fps
    }

    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws: WebSocket, raw: string) {
    const input = JSON.parse(raw);
    const userId = this.doState.getTags(ws).find((t) => t.startsWith("player:"))?.slice(7);
    if (!userId) return;

    // Xác thực + áp dụng đầu vào vào state game
    this.applyInput(userId, input);
  }

  async alarm() {
    // Tick: cập nhật vật lý, kiểm tra va chạm
    this.tick();

    // Phát sóng state
    const payload = JSON.stringify({ type: "state", state: this.state });
    for (const ws of this.doState.getWebSockets()) {
      try {
        ws.send(payload);
      } catch {}
    }

    // Lên lịch tick tiếp theo
    if (this.doState.getWebSockets().length > 0) {
      await this.doState.storage.setAlarm(Date.now() + 16);
    }
  }

  tick() { /* cập nhật vật lý */ }
  applyInput(userId: string, input: any) { /* ... */ }
}

Alarm là cách chuẩn để lên lịch. Không dùng setInterval — DO có thể ngủ đông, interval mất.


Pattern 4: presence

User online/offline, báo đang gõ.

export class PresenceDO {
  users: Map<string, { lastSeen: number; status: string }> = new Map();

  constructor(readonly state: DurableObjectState) {
    state.blockConcurrencyWhile(async () => {
      const saved = await state.storage.get<any>("users");
      if (saved) this.users = new Map(Object.entries(saved));
    });
  }

  async fetch(request: Request): Promise<Response> {
    const pair = new WebSocketPair();
    const [client, server] = [pair[0], pair[1]];

    const userId = new URL(request.url).searchParams.get("userId")!;
    this.state.acceptWebSocket(server, [`user:${userId}`]);

    this.users.set(userId, { lastSeen: Date.now(), status: "online" });
    this.broadcastPresence();

    // Lên lịch dọn dẹp
    const alarm = await this.state.storage.getAlarm();
    if (!alarm) {
      await this.state.storage.setAlarm(Date.now() + 60_000);
    }

    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws: WebSocket, raw: string) {
    const userId = this.state.getTags(ws).find((t) => t.startsWith("user:"))?.slice(5);
    if (!userId) return;

    const { type } = JSON.parse(raw);
    if (type === "heartbeat") {
      const user = this.users.get(userId);
      if (user) user.lastSeen = Date.now();
    } else if (type === "typing") {
      const user = this.users.get(userId);
      if (user) user.status = "typing";
      this.broadcastPresence();
    }
  }

  async alarm() {
    // Đánh dấu user stale là offline
    const now = Date.now();
    let changed = false;
    for (const [id, user] of this.users) {
      if (now - user.lastSeen > 60_000 && user.status !== "offline") {
        user.status = "offline";
        changed = true;
      }
    }
    if (changed) this.broadcastPresence();

    // Lên lịch lại
    if (this.users.size > 0) {
      await this.state.storage.setAlarm(Date.now() + 60_000);
    }
  }

  broadcastPresence() {
    const payload = JSON.stringify({
      type: "presence",
      users: Object.fromEntries(this.users),
    });
    for (const ws of this.state.getWebSockets()) {
      try {
        ws.send(payload);
      } catch {}
    }
  }
}

Pattern 5: rate limiter

Giới hạn tốc độ per-user strong consistency (không eventual như KV).

export class RateLimiter {
  constructor(readonly state: DurableObjectState) {}

  async fetch(request: Request): Promise<Response> {
    const { limit, windowSec } = await request.json();

    const now = Date.now();
    const windowStart = await this.state.storage.get<number>("windowStart") ?? now;
    const count = await this.state.storage.get<number>("count") ?? 0;

    if (now - windowStart > windowSec * 1000) {
      // Đặt lại cửa sổ
      await this.state.storage.put("windowStart", now);
      await this.state.storage.put("count", 1);
      return Response.json({ allowed: true, remaining: limit - 1 });
    }

    if (count >= limit) {
      return Response.json({ allowed: false, remaining: 0 }, { status: 429 });
    }

    await this.state.storage.put("count", count + 1);
    return Response.json({ allowed: true, remaining: limit - count - 1 });
  }
}

Worker dùng như sau:

const userId = getUserId(request);
const id = env.RATE_LIMITER.idFromName(`user:${userId}`);
const stub = env.RATE_LIMITER.get(id);
const result = await stub.fetch(new Request("https://x", {
  method: "POST",
  body: JSON.stringify({ limit: 100, windowSec: 60 }),
}));

const { allowed } = await result.json();
if (!allowed) return new Response("Rate limited", { status: 429 });

Lưu ý: Cloudflare có quy tắc rate limiting WAF tích hợp sẵn cho HTTP traffic — nhanh hơn, không tốn DO. Dùng DO rate limiter khi cần logic phức tạp (cửa sổ trượt, per-endpoint, logic nghiệp vụ).


Pattern 6: single-flight (dedupe)

Ngăn công việc trùng đồng thời. Ví dụ: 100 request cùng lúc hỏi LLM cùng prompt → chỉ gọi 1 lần, chia sẻ kết quả.

export class SingleFlight {
  inFlight: Map<string, Promise<any>> = new Map();

  constructor(readonly state: DurableObjectState, readonly env: Env) {}

  async fetch(request: Request): Promise<Response> {
    const { key, work } = await request.json();

    if (!this.inFlight.has(key)) {
      this.inFlight.set(key, this.doExpensiveWork(work).finally(() => {
        this.inFlight.delete(key);
      }));
    }

    const result = await this.inFlight.get(key)!;
    return Response.json(result);
  }

  async doExpensiveWork(work: any): Promise<any> {
    // Gọi LLM, tính toán nặng, API ngoài
    return await this.env.AI.run(work.model, { messages: work.messages });
  }
}

DO single-writer đảm bảo 100 request đồng thời vào cùng DO ID → 99 await promise, 1 chạy công việc. Kết quả trả về tất cả.


Storage: KV vs SQL

DO có 2 storage backend:

Storage KV (bản gốc)

await state.storage.put("key", value);
const v = await state.storage.get("key");
await state.storage.delete("key");
await state.storage.list({ prefix: "foo:" });

Đơn giản, nhanh cho key-value. Giới hạn ~128KB/value.

Storage SQL (beta, khuyến nghị)

state.storage.sql.exec(`
  CREATE TABLE IF NOT EXISTS messages (
    id TEXT PRIMARY KEY,
    user TEXT,
    text TEXT,
    ts INTEGER
  )
`);

state.storage.sql.exec(
  "INSERT INTO messages VALUES (?, ?, ?, ?)",
  id, user, text, ts
);

const results = state.storage.sql.exec(
  "SELECT * FROM messages ORDER BY ts DESC LIMIT 50"
).toArray();

Storage SQL:

  • SQLite per-DO, giới hạn ~10GB.
  • Query phong phú hơn (JOIN, aggregate, index).
  • Tốt hơn cho state collab, state game phức tạp.
  • new_sqlite_classes trong migrations.

Khuyến nghị SQL storage cho mọi DO mới, trừ trường hợp sử dụng đơn giản key-value.


Migration giữa phiên bản

DO có hệ thống migration tách biệt với D1. Thay đổi tên class hoặc backend → thêm entry migration:

{
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["RoomDO"] },
    { "tag": "v2", "new_sqlite_classes": ["GameDO"] },
    { "tag": "v3", "renamed_classes": [{ "from": "OldName", "to": "NewName" }] },
    { "tag": "v4", "deleted_classes": ["DeadDO"] }
  ]
}

Migration được áp dụng khi triển khai. DO hiện tại tiếp tục chạy, DO mới dùng class mới.

Cẩn thận: xóa class = mất dữ liệu của DO đó. Có cảnh báo khi triển khai.


Lên lịch alarm

setAlarm(timestamp) lên lịch method alarm() chạy tại timestamp. Single-flight (chỉ 1 alarm chờ). Bền vững qua khởi động lại.

await this.state.storage.setAlarm(Date.now() + 60_000);

async alarm() {
  // chạy sau 60s
  doCleanup();
  // lên lịch lại nếu cần
  await this.state.storage.setAlarm(Date.now() + 60_000);
}

Trường hợp sử dụng: dọn dẹp stale, vòng tick game, gửi email nhắc, hết hạn session.

Tối đa 1 alarm per DO. Cần nhiều lịch → lưu queue trong storage, điều phối từ alarm.


Khi nào KHÔNG dùng DO

DO powerful nhưng không phải cho mọi thing:

❌ User table 1M+ row

DO per-user = 1M instance. Storage cost + management nightmare. Dùng D1 table.

❌ CRUD API đơn giản

POST /posts, GET /posts — không cần coordination. D1 + Worker handler là đủ.

❌ Global counter

Global state (view count toàn app) — DO single-instance thành bottleneck. Dùng Analytics Engine hoặc D1 với sharding.

❌ Queue processing

Background job fan-out — dùng Queue (Part 8).

❌ Read-heavy cache

Cache data thường xuyên đọc, hiếm viết — KV rẻ hơn + nhanh hơn.

✅ Khi nào DO đúng

  • Coordination: lock, serialize, dedupe.
  • WebSocket stateful: chat, collab, game.
  • Per-entity state: per-user session, per-room, per-document.
  • Alarm scheduling: deadline, reminder, retry.

Nếu không thấy “coordination” hoặc “WebSocket” hoặc “per-entity stateful”, review lại có cần DO không.


Gotcha

① DO location không deterministic

DO chạy PoP gần request đầu tiên. User tạo room ở VN → DO ở Singapore. User Mỹ join → thêm RTT. Với app global, cân nhắc:

  • Shard ID theo region prefix (us-general, asia-general).
  • Accept latency cho cross-region use case hiếm.

② Concurrent fetch vs event loop

DO xử lý request tuần tự (single-thread event loop). 100 concurrent fetch vào cùng DO = queue. Nếu 1 fetch slow, block các fetch khác. Async I/O giải phóng turn, nhưng CPU-bound block.

③ blockConcurrencyWhile cho init

Load state từ storage trong constructor cần blockConcurrencyWhile để request không xử lý trước khi state sẵn sàng.

constructor(readonly state: DurableObjectState) {
  state.blockConcurrencyWhile(async () => {
    this.data = await state.storage.get("data");
  });
}

④ Hibernation mất closure

Standard WebSocket: closure capture variable. Hibernation: handler là class method, không capture. Refactor để dùng this.

⑤ Max 32768 connection per DO

Single DO instance limit ~32k WebSocket. Chat room siêu lớn cần shard nhiều DO.

⑥ Storage transaction

SQL storage auto transaction per exec. Nhiều exec cần atomic:

state.storage.transaction(() => {
  state.storage.sql.exec("UPDATE ...");
  state.storage.sql.exec("INSERT ...");
});

⑦ DO delete khó

state.storage.deleteAll() clear storage, nhưng DO instance vẫn tồn tại. Truly delete = migrate deleted_classes.

⑧ Local dev với DO

wrangler dev emulate DO qua Miniflare. Hibernation API support trong Miniflare v4+. Một số behavior (location, cross-PoP) không replicate được local.


Observability

Metrics cho DO:

  • Active DO count: bao nhiêu DO đang chạy.
  • Duration (GB-s): compute time.
  • Storage (GB-month): storage size.
  • WebSocket connection count: active connection (hibernated không count compute).
  • Requests: fetch vào DO.
  • Error rate: % DO return 5xx.

Dashboard: Workers & Pages → chọn Worker → Durable Objects tab.

Log: tail Workers logs include DO log. Chi tiết Part 17.


Pricing nhanh

  • Requests: $0.15/1M request (same Workers).
  • Duration: $12.50/million GB-s (khi DO active).
  • Storage: $0.20/GB/month.
  • Hibernated WebSocket: không charge duration, chỉ charge storage.

Ví dụ: 1000 chat room, trung bình 50 user/room, user active 1h/ngày, hibernation enabled:

  • Active duration: 1000 × 1h = 1000 DO-hour/ngày = ~3600 GB-s/ngày × $12.5/1M = $0.05/ngày = ~$1.5/tháng.
  • Storage: 1000 × 1MB = 1GB = $0.20/tháng.
  • Requests: tiny.

~$2/tháng cho 1000 active chat room. Self-host Socket.io trên AWS EC2 nhỏ nhất (t3.small) = $15/tháng, không auto-scale.


Production checklist

  • new_sqlite_classes cho mọi new DO.
  • Hibernation API (acceptWebSocket) cho WebSocket use case.
  • blockConcurrencyWhile cho initialize state từ storage.
  • Broadcast dùng getWebSockets(), không track sessions thủ công.
  • Error handling trong send() (socket có thể đã close).
  • Storage write optimize (batch, debounce snapshot).
  • Migration entry cho mọi class change.
  • Alarm reschedule khi còn work, stop khi empty.
  • Tag WebSocket để identify user sau hibernation.
  • Monitor DO count + storage để detect DO leak.
  • Không dùng DO cho use case không cần coordination.

Kết

Durable Object là primitive unique: single-writer coordination + WebSocket + persistent state + alarm. Combo này cho phép build chat, collab, game ở edge mà hầu hết platform khác cần external infra (Redis + cluster + Socket.io).

Nhưng DO không phải database. Đúng tool cho coordination layer, sai tool cho CRUD thông thường.

Part 16: Stream + Images — video streaming, on-the-fly image resize, và CDN pattern cho media. Closes Block 4.


Tham khảo