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<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<ChatRoom>;
}
export class ChatRoom extends DurableObject<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<ChatRoom>;
}
export class ChatRoom extends DurableObject<Env> {
async fetch(request: Request): Promise<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, andwebSocketClosehandlers for their respective WebSocket events. - With the
web_socket_auto_reply_to_closecompatibility flag (enabled by default on compatibility dates on or after2026-04-07), the runtime automatically completes the close handshake. Callingws.close()inwebSocketCloseis still safe but no longer required. On older compatibility dates, you must callws.close()to avoid1006abnormal 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<ChatRoom>;
}
type ConnectionState = {
userId: string;
username: string;
joinedAt: number;
};
export class ChatRoom extends DurableObject<Env> {
async fetch(request: Request): Promise<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 thealarm()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; }
