Rules of Durable Objects·续3

const stub = env.CHAT_ROOM.getByName(roomId);

// Initialize on first access await stub.init("system");

return new Response(Room ${await stub.getRoomId()} ready); }, };


*index.tsts*

```ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
    CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

export class ChatRoom extends DurableObject<Env> {
    // The name passed to getByName() or idFromName()
    getRoomId(): string {
        const roomId = this.ctx.id.name;
        if (!roomId) {
            throw new Error("ChatRoom must be accessed by name.");
        }
        return roomId;
    }

// Store application-defined data that is not part of the ID
    async init(createdBy: string) {
        const existing = await this.ctx.storage.get("createdBy");
        if (existing) {
            return; // Already initialized
        }

await this.ctx.storage.put("createdBy", createdBy);
        await this.ctx.storage.put("createdAt", Date.now());
    }
}

export default {
    async fetch(request: Request, env: Env): Promise<Response> {
        const url = new URL(request.url);
        const roomId = url.searchParams.get("room") ?? "lobby";

const stub = env.CHAT_ROOM.getByName(roomId);

// Initialize on first access
        await stub.init("system");

return new Response(`Room ${await stub.getRoomId()} ready`);
    },
};

Always await RPC calls

When calling methods on a Durable Object stub, always use await. Unawaited calls create dangling promises, causing errors to be swallowed and return values to be lost.

index.jsjs

import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
    async sendMessage(userId, content) {
        const result = this.ctx.storage.sql.exec(
            "INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?) RETURNING id",
            userId,
            content,
            Date.now(),
        );
        return result.one().id;
    }
}

export default {
    async fetch(request, env) {
        const id = env.CHAT_ROOM.idFromName("lobby");
        const stub = env.CHAT_ROOM.get(id);

// 🔴 Bad: Not awaiting the call
        // The message ID is lost, and any errors are swallowed
        stub.sendMessage("user-123", "Hello");

// ✅ Good: Properly awaited
        const messageId = await stub.sendMessage("user-123", "Hello");

return Response.json({ messageId });
    },
};

index.tsts

import { DurableObject } from "cloudflare:workers";

export interface Env {
    CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

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

export default {
    async fetch(request: Request, env: Env): Promise&lt;Response> {
        const id = env.CHAT_ROOM.idFromName("lobby");
        const stub = env.CHAT_ROOM.get(id);

// 🔴 Bad: Not awaiting the call
        // The message ID is lost, and any errors are swallowed
        stub.sendMessage("user-123", "Hello");

// ✅ Good: Properly awaited
        const messageId = await stub.sendMessage("user-123", "Hello");

return Response.json({ messageId });
    },
};

Error handling

Handle errors and use exception boundaries

Uncaught exceptions in a Durable Object can leave it in an unknown state and may cause the runtime to terminate the instance. Wrap risky operations in try...catch blocks, and handle errors appropriately.

index.jsjs

import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
    async processMessage(userId, content) {
        // ✅ Good: Wrap risky operations in try...catch
        try {
            // Validate input before processing
            if (!content || content.length > 10000) {
                throw new Error("Invalid message content");
            }

this.ctx.storage.sql.exec(
                "INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
                userId,
                content,
                Date.now(),
            );

// External call that might fail
            await this.notifySubscribers(content);
        } catch (error) {
            // Log the error for debugging
            console.error("Failed to process message:", error);

// Re-throw if it's a validation error (don't retry)
            if (error instanceof Error && error.message.includes("Invalid")) {
                throw error;
            }

// For transient errors, you might want to handle differently
            throw error;
        }
    }

async notifySubscribers(content) {
        // External notification logic
    }
}

index.tsts

import { DurableObject } from "cloudflare:workers";

export interface Env {
    CHAT_ROOM: DurableObjectNamespace&lt;ChatRoom>;
}

export class ChatRoom extends DurableObject&lt;Env> {
    async processMessage(userId: string, content: string) {
        // ✅ Good: Wrap risky operations in try...catch
        try {
            // Validate input before processing
            if (!content || content.length > 10000) {
                throw new Error("Invalid message content");
            }

this.ctx.storage.sql.exec(
                "INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
                userId,
                content,
                Date.now()
            );

// External call that might fail
            await this.notifySubscribers(content);
        } catch (error) {
            // Log the error for debugging
            console.error("Failed to process message:", error);

// Re-throw if it's a validation error (don't retry)
            if (error instanceof Error && error.message.includes("Invalid")) {
                throw error;
            }

// For transient errors, you might want to handle differently
            throw error;
        }
    }

private async notifySubscribers(content: string) {
        // External notification logic
    }
}

When calling Durable Objects from a Worker, errors may include .retryable and .overloaded properties indicating whether the operation can be retried. For transient failures, implement exponential backoff to avoid overwhelming the system.

Refer to Error handling for details on error properties, retry strategies, and exponential backoff patterns.

WebSockets and real-time

Use the Hibernatable WebSockets API for cost efficiency

The Hibernatable WebSockets API allows Durable Objects to sleep while maintaining WebSocket connections. This significantly reduces costs for applications with many idle connections.

index.jsjs

import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
    async fetch(request) {
        const url = new URL(request.url);

if (url.pathname === "/websocket") {
            // Check for WebSocket upgrade
            if (request.headers.get("Upgrade") !== "websocket") {
                return new Response("Expected WebSocket", { status: 400 });
            }

const pair = new WebSocketPair();
            const [client, server] = Object.values(pair);

// Accept the WebSocket with Hibernation API
            this.ctx.acceptWebSocket(server);

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

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

// Called when a message is received (even after hibernation)
    async webSocketMessage(ws, message) {
        const data = typeof message === "string" ? message : "binary data";

// Broadcast to all connected clients
        for (const client of this.ctx.getWebSockets()) {
            if (client !== ws && client.readyState === WebSocket.OPEN) {
                client.send(data);
            }
        }
    }

// Called when a WebSocket is closed
    async webSocketClose(ws, code, reason, wasClean) {
        // With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
        // auto-replies to Close frames. Calling close() is safe but no longer required.
        ws.close(code, reason);
        console.log(`WebSocket closed: ${code} ${reason}`);
    }

// Called when a WebSocket error occurs
    async webSocketError(ws, error) {
        console.error("WebSocket error:", error);
    }
}

index.tsts

import { DurableObject } from "cloudflare:workers";

export interface Env {
    CHAT_ROOM: DurableObjectNamespace&lt;ChatRoom>;
}

export class ChatRoom extends DurableObject&lt;Env> {
    async fetch(request: Request): Promise&lt;Response> {
        const url = new URL(request.url);

if (url.pathname === "/websocket") {
            // Check for WebSocket upgrade
            if (request.headers.get("Upgrade") !== "websocket") {
                return new Response("Expected WebSocket", { status: 400 });
            }

const pair = new WebSocketPair();
            const [client, server] = Object.values(pair);

// Accept the WebSocket with Hibernation API
            this.ctx.acceptWebSocket(server);

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

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

// Called when a message is received (even after hibernation)
    async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
        const data = typeof message === "string" ? message : "binary data";

// Broadcast to all connected clients
        for (const client of this.ctx.getWebSockets()) {
            if (client !== ws && client.readyState === WebSocket.OPEN) {
                client.send(data);
            }
        }
    }

// Called when a WebSocket is closed
    async webSocketClose(
        ws: WebSocket,
        code: number,
        reason: string,
        wasClean: boolean
    ) {
        // With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
        // auto-replies to Close frames. Calling close() is safe but no longer required.
        ws.close(code, reason);
        console.log(`WebSocket closed: ${code} ${reason}`);
    }

// Called when a WebSocket error occurs
    async webSocketError(ws: WebSocket, error: unknown) {
        console.error("WebSocket error:", error);
    }
}

With the Hibernation API, your Durable Object can go to sleep when there is no active JavaScript execution, but WebSocket connections remain open. When a message arrives, the Durable Object wakes up automatically.

Best practices:

  • The WebSocket Hibernation API exposes webSocketError, webSocketMessage, and webSocketClose handlers for their respective WebSocket events.
  • With the web_socket_auto_reply_to_close compatibility flag (enabled by default on compatibility dates on or after 2026-04-07), the runtime automatically completes the close handshake. Calling ws.close() in webSocketClose is still safe but no longer required. On older compatibility dates, you must call ws.close() to avoid 1006 abnormal closure errors.

Refer to WebSockets for more details.

Use serializeAttachment() to persist per-connection state

WebSocket attachments let you store metadata for each connection that survives hibernation. Use this for user IDs, session tokens, or other per-connection data.

index.jsjs

import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
    async fetch(request) {
        const url = new URL(request.url);

if (url.pathname === "/websocket") {
            if (request.headers.get("Upgrade") !== "websocket") {
                return new Response("Expected WebSocket", { status: 400 });
            }

const userId = url.searchParams.get("userId") ?? "anonymous";
            const username = url.searchParams.get("username") ?? "Anonymous";

const pair = new WebSocketPair();
            const [client, server] = Object.values(pair);

this.ctx.acceptWebSocket(server);

// Store per-connection state that survives hibernation
            const state = {
                userId,
                username,
                joinedAt: Date.now(),
            };
            server.serializeAttachment(state);

// Broadcast join message
            this.broadcast(`${username} joined the chat`);

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

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

async webSocketMessage(ws, message) {
        // Retrieve the connection state (works even after hibernation)
        const state = ws.deserializeAttachment();

const chatMessage = JSON.stringify({
            userId: state.userId,
            username: state.username,
            content: message,
            timestamp: Date.now(),
        });

this.broadcast(chatMessage);
    }

async webSocketClose(ws, code, reason) {
        // With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
        // auto-replies to Close frames. Calling close() is safe but no longer required.
        ws.close(code, reason);
        const state = ws.deserializeAttachment();
        this.broadcast(`${state.username} left the chat`);
    }

broadcast(message) {
        for (const client of this.ctx.getWebSockets()) {
            if (client.readyState === WebSocket.OPEN) {
                client.send(message);
            }
        }
    }
}

index.tsts

import { DurableObject } from "cloudflare:workers";

export interface Env {
    CHAT_ROOM: DurableObjectNamespace&lt;ChatRoom>;
}

type ConnectionState = {
    userId: string;
    username: string;
    joinedAt: number;
};

export class ChatRoom extends DurableObject&lt;Env> {
    async fetch(request: Request): Promise&lt;Response> {
        const url = new URL(request.url);

if (url.pathname === "/websocket") {
            if (request.headers.get("Upgrade") !== "websocket") {
                return new Response("Expected WebSocket", { status: 400 });
            }

const userId = url.searchParams.get("userId") ?? "anonymous";
            const username = url.searchParams.get("username") ?? "Anonymous";

const pair = new WebSocketPair();
            const [client, server] = Object.values(pair);

this.ctx.acceptWebSocket(server);

// Store per-connection state that survives hibernation
            const state: ConnectionState = {
                userId,
                username,
                joinedAt: Date.now(),
            };
            server.serializeAttachment(state);

// Broadcast join message
            this.broadcast(`${username} joined the chat`);

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

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

async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
        // Retrieve the connection state (works even after hibernation)
        const state = ws.deserializeAttachment() as ConnectionState;

const chatMessage = JSON.stringify({
            userId: state.userId,
            username: state.username,
            content: message,
            timestamp: Date.now(),
        });

this.broadcast(chatMessage);
    }

async webSocketClose(ws: WebSocket, code: number, reason: string) {
        // With web_socket_auto_reply_to_close (compat date >= 2026-04-07), the runtime
        // auto-replies to Close frames. Calling close() is safe but no longer required.
        ws.close(code, reason);
        const state = ws.deserializeAttachment() as ConnectionState;
        this.broadcast(`${state.username} left the chat`);
    }

private broadcast(message: string) {
        for (const client of this.ctx.getWebSockets()) {
            if (client.readyState === WebSocket.OPEN) {
                client.send(message);
            }
        }
    }
}

Scheduling and lifecycle

Use alarms for per-entity scheduled tasks

Each Durable Object can schedule its own future work using the Alarms API, allowing a Durable Object to execute background tasks on any interval without an incoming request, RPC call, or WebSocket message.

Key points about alarms:

  • setAlarm(timestamp) schedules the alarm() handler to run at any time in the future (millisecond precision)
  • Alarms do not repeat automatically — you must call setAlarm() again to schedule the next execution
  • Only schedule alarms when there is work to do — avoid waking up every Durable Object on short intervals (seconds), as each alarm invocation incurs costs

index.jsjs

```js import { DurableObject } from "cloudflare:workers";

export class GameMatch extends DurableObject { async startGame(durationMs = 60000) { await this.ctx.storage.put("gameStarted", Date.now()); await this.ctx.storage.put("gameActive", true);

// Schedule the game to end after the duration await this.ctx.storage.setAlarm(Date.now() + durationMs); }

// Called when the alarm fires async alarm(alarmInfo) { const isActive = await this.ctx.storage.get("gameActive");

if (!isActive) { return; // Game was already ended }

// End the game await this.ctx.storage.put("gameActive", false); await this.ctx.storage.put("gameEnded", Date.now());

// Calculate final scores, notify players, etc. try { await this.calculateFinalScores(); } catch (err) { // If we're almost out of retries but still have work to do, schedule a new alarm // rather than letting our retries run out to ensure we keep getting invoked. if (alarmInfo && alarmInfo.retryCount >= 5) { await this.ctx.storage.setAlarm(Date.now() + 30 * 1000); return; } throw err; }