Skip to content
Cloudflare Docs

Rules of Durable Objects

Durable Objects provide a powerful primitive for building stateful, coordinated applications. Each Durable Object is a single-threaded, globally-unique instance with its own persistent storage. Understanding how to design around these properties is essential for building effective applications.

This is a guidebook on how to build more effective and correct Durable Object applications.

When to use Durable Objects

Use Durable Objects for stateful coordination, not stateless request handling

Workers are stateless functions: each request may run on a different instance, in a different location, with no shared memory between requests. Durable Objects are stateful compute: each instance has a unique identity, runs in a single location, and maintains state across requests.

Use Durable Objects when you need:

  • Coordination — Multiple clients need to interact with shared state (chat rooms, multiplayer games, collaborative documents)
  • Strong consistency — Operations must be serialized to avoid race conditions (inventory management, booking systems, turn-based games)
  • Per-entity storage — Each user, tenant, or resource needs its own isolated database (multi-tenant SaaS, per-user data)
  • Persistent connections — Long-lived WebSocket connections that survive across requests (real-time notifications, live updates)
  • Scheduled work per entity — Each entity needs its own timer or scheduled task (subscription renewals, game timeouts)

Use plain Workers when you need:

  • Stateless request handling — API endpoints, proxies, or transformations with no shared state
  • Maximum global distribution — Requests should be handled at the nearest edge location
  • High fan-out — Each request is independent and can be processed in parallel
index.js
import { DurableObject } from "cloudflare:workers";
// ✅ Good use of Durable Objects: Seat booking requires coordination
// All booking requests for a venue must be serialized to prevent double-booking
export class SeatBooking extends DurableObject {
async bookSeat(seatId, userId) {
// Check if seat is already booked
const existing = this.ctx.storage.sql
.exec("SELECT user_id FROM bookings WHERE seat_id = ?", seatId)
.toArray();
if (existing.length > 0) {
return { success: false, message: "Seat already booked" };
}
// Book the seat - this is safe because Durable Objects are single-threaded
this.ctx.storage.sql.exec(
"INSERT INTO bookings (seat_id, user_id, booked_at) VALUES (?, ?, ?)",
seatId,
userId,
Date.now(),
);
return { success: true, message: "Seat booked successfully" };
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const eventId = url.searchParams.get("event") ?? "default";
// Route to a Durable Object by event ID
// All bookings for the same event go to the same instance
const id = env.BOOKING.idFromName(eventId);
const booking = env.BOOKING.get(id);
const { seatId, userId } = await request.json();
const result = await booking.bookSeat(seatId, userId);
return Response.json(result, {
status: result.success ? 200 : 409,
});
},
};

A common pattern is to use Workers as the stateless entry point that routes requests to Durable Objects when coordination is needed. The Worker handles authentication, validation, and response formatting, while the Durable Object handles the stateful logic.

Design and sharding

Model your Durable Objects around your "atom" of coordination

The most important design decision is choosing what each Durable Object represents. Create one Durable Object per logical unit that needs coordination: a chat room, a game session, a document, a user's data, or a tenant's workspace.

This is the key insight that makes Durable Objects powerful. Instead of a shared database with locks, each "atom" of your application gets its own single-threaded execution environment with private storage.

index.js
import { DurableObject } from "cloudflare:workers";
// Each chat room is its own Durable Object instance
export class ChatRoom extends DurableObject {
async sendMessage(userId, message) {
// All messages to this room are processed sequentially by this single instance.
// No race conditions, no distributed locks needed.
this.ctx.storage.sql.exec(
"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
userId,
message,
Date.now(),
);
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const roomId = url.searchParams.get("room") ?? "lobby";
// Each room ID maps to exactly one Durable Object instance globally
const id = env.CHAT_ROOM.idFromName(roomId);
const stub = env.CHAT_ROOM.get(id);
await stub.sendMessage("user-123", "Hello, room!");
return new Response("Message sent");
},
};

Do not create a single "global" Durable Object that handles all requests:

index.js
import { DurableObject } from "cloudflare:workers";
// 🔴 Bad: A single Durable Object handling ALL chat rooms
export class ChatRoom extends DurableObject {
async sendMessage(roomId, userId, message) {
// All messages for ALL rooms go through this single instance.
// This becomes a bottleneck as traffic grows.
this.ctx.storage.sql.exec(
"INSERT INTO messages (room_id, user_id, content) VALUES (?, ?, ?)",
roomId,
userId,
message,
);
}
}
export default {
async fetch(request, env) {
// 🔴 Bad: Always using the same ID means one global instance
const id = env.CHAT_ROOM.idFromName("global");
const stub = env.CHAT_ROOM.get(id);
await stub.sendMessage("room-123", "user-456", "Hello!");
return new Response("Sent");
},
};

Use deterministic IDs for predictable routing

Use getByName() with meaningful, deterministic strings for consistent routing. The same input always produces the same Durable Object ID, ensuring requests for the same logical entity always reach the same instance.

index.js
import { DurableObject } from "cloudflare:workers";
export class GameSession extends DurableObject {
async join(playerId) {
// Game logic here
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const gameId = url.searchParams.get("game");
if (!gameId) {
return new Response("Missing game ID", { status: 400 });
}
// ✅ Good: Deterministic ID from a meaningful string
// All requests for "game-abc123" go to the same Durable Object
const stub = env.GAME_SESSION.getByName(gameId);
await stub.join("player-xyz");
return new Response("Joined game");
},
};

Creating a stub does not instantiate or wake up the Durable Object. The Durable Object is only activated when you call a method on the stub.

Use newUniqueId() only when you need a new, random instance and will store the mapping externally:

index.js
import { DurableObject } from "cloudflare:workers";
export class GameSession extends DurableObject {
async join(playerId) {
// Game logic here
}
}
export default {
async fetch(request, env) {
// newUniqueId() creates a random ID - useful when creating new instances
// You must store this ID somewhere (e.g., D1) to find it again later
const id = env.GAME_SESSION.newUniqueId();
const stub = env.GAME_SESSION.get(id);
// Store the mapping: gameCode -> id.toString()
// await env.DB.prepare("INSERT INTO games (code, do_id) VALUES (?, ?)").bind(gameCode, id.toString()).run();
return Response.json({ gameId: id.toString() });
},
};

Do not put all your data in a single Durable Object. When you have hierarchical data (workspaces containing projects, game servers managing matches), create separate child Durable Objects for each entity. The parent coordinates and tracks children, while children handle their own state independently.

This enables parallelism: operations on different children can happen concurrently, while each child maintains its own single-threaded consistency (read more about this pattern).

index.js
import { DurableObject } from "cloudflare:workers";
// Parent: Coordinates matches, but doesn't store match data
export class GameServer extends DurableObject {
async createMatch(matchName) {
const matchId = crypto.randomUUID();
// Store reference to the child in parent's database
this.ctx.storage.sql.exec(
"INSERT INTO matches (id, name, created_at) VALUES (?, ?, ?)",
matchId,
matchName,
Date.now(),
);
// Initialize the child Durable Object
const childId = this.env.GAME_MATCH.idFromName(matchId);
const childStub = this.env.GAME_MATCH.get(childId);
await childStub.init(matchId, matchName);
return matchId;
}
async listMatches() {
// Parent knows about all matches without waking up each child
const cursor = this.ctx.storage.sql.exec(
"SELECT id, name FROM matches ORDER BY created_at DESC",
);
return cursor.toArray();
}
}
// Child: Handles its own game state independently
export class GameMatch extends DurableObject {
async init(matchId, matchName) {
await this.ctx.storage.put("matchId", matchId);
await this.ctx.storage.put("matchName", matchName);
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS players (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
score INTEGER DEFAULT 0
)
`);
}
async addPlayer(playerId, playerName) {
this.ctx.storage.sql.exec(
"INSERT INTO players (id, name, score) VALUES (?, ?, 0)",
playerId,
playerName,
);
}
async updateScore(playerId, score) {
this.ctx.storage.sql.exec(
"UPDATE players SET score = ? WHERE id = ?",
score,
playerId,
);
}
}

With this pattern:

  • Listing matches only queries the parent (children stay hibernated)
  • Different matches process player actions in parallel
  • Each match has its own SQLite database for player data

Consider location hints for latency-sensitive applications

By default, a Durable Object is created near the location of the first request it receives. For most applications, this works well. However, you can provide a location hint to influence where the Durable Object is created.

index.js
import { DurableObject } from "cloudflare:workers";
export class GameSession extends DurableObject {
// Game session logic
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const gameId = url.searchParams.get("game") ?? "default";
const region = url.searchParams.get("region") ?? "wnam"; // Western North America
// Provide a location hint for where this Durable Object should be created
const id = env.GAME_SESSION.idFromName(gameId, {
locationHint: region,
});
const stub = env.GAME_SESSION.get(id);
return new Response("Connected to game session");
},
};

Location hints are suggestions, not guarantees. Refer to Data location for available regions and details.

Storage and state

Use SQLite-backed Durable Objects

SQLite storage is the recommended storage backend for new Durable Objects. It provides a familiar SQL API for relational queries, indexes, transactions, and better performance than the legacy key-value storage backed Durable Objects. SQLite Durable Objects also support the KV API in synchronous and asynchronous versions.

Configure your Durable Object class to use SQLite storage in your Wrangler configuration:

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

Then use the SQL API in your Durable Object:

index.js
import { DurableObject } from "cloudflare:workers";
export class ChatRoom extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
// Create tables on first instantiation
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
)
`);
}
async addMessage(userId, content) {
this.ctx.storage.sql.exec(
"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
userId,
content,
Date.now(),
);
}
async getRecentMessages(limit = 50) {
// Use type parameter for typed results
const cursor = this.ctx.storage.sql.exec(
"SELECT * FROM messages ORDER BY created_at DESC LIMIT ?",
limit,
);
return cursor.toArray();
}
}

Refer to Access Durable Objects storage for more details on the SQL API.

Initialize storage and run migrations in the constructor

Use blockConcurrencyWhile() in the constructor to run migrations and initialize state before any requests are processed. This ensures your schema is ready and prevents race conditions during initialization.

index.js
import { DurableObject } from "cloudflare:workers";
export class ChatRoom extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
// blockConcurrencyWhile() ensures no requests are processed until this completes
ctx.blockConcurrencyWhile(async () => {
await this.migrate();
});
}
async migrate() {
// Check current schema version
const version =
this.ctx.storage.sql.exec("PRAGMA user_version").one()?.version ?? 0;
if (version < 1) {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
PRAGMA user_version = 1;
`);
}
if (version < 2) {
// Future migration: add a new column
this.ctx.storage.sql.exec(`
ALTER TABLE messages ADD COLUMN edited_at INTEGER;
PRAGMA user_version = 2;
`);
}
}
}

Understand the difference between in-memory state and persistent storage

Durable Objects provide multiple state management layers, each with different characteristics:

TypeSpeedPersistenceUse Case
In-memory (class properties)FastestLost on eviction or crashCaching, active connections
SQLite storageFastDurable across restartsPrimary data storage
External (R2, D1)VariableDurable, cross-DO accessibleLarge files, shared data

In-memory state is not preserved if the Durable Object is evicted from memory due to inactivity, or if it crashes from an uncaught exception. Always persist important state to SQLite storage.

index.js
import { DurableObject } from "cloudflare:workers";
export class ChatRoom extends DurableObject {
// In-memory cache - fast but NOT preserved across evictions or crashes
messageCache = null;
async getRecentMessages() {
// Return from cache if available (only valid while DO is in memory)
if (this.messageCache !== null) {
return this.messageCache;
}
// Otherwise, load from durable storage
const cursor = this.ctx.storage.sql.exec(
"SELECT * FROM messages ORDER BY created_at DESC LIMIT 100",
);
this.messageCache = cursor.toArray();
return this.messageCache;
}
async addMessage(userId, content) {
// ✅ Always persist to durable storage first
this.ctx.storage.sql.exec(
"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
userId,
content,
Date.now(),
);
// Then update the cache (if it exists)
// If the DO crashes here, the message is still saved in SQLite
this.messageCache = null; // Invalidate cache
}
}

Create indexes for frequently-queried columns

Just like any database, indexes dramatically improve read performance for frequently-filtered columns. The cost is slightly more storage and marginally slower writes.

index.js
import { DurableObject } from "cloudflare:workers";
export class ChatRoom extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
-- Index for queries filtering by user
CREATE INDEX IF NOT EXISTS idx_messages_user_id ON messages(user_id);
-- Index for time-based queries (recent messages)
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
-- Composite index for user + time queries
CREATE INDEX IF NOT EXISTS idx_messages_user_time ON messages(user_id, created_at);
`);
});
}
// This query benefits from idx_messages_user_time
async getUserMessages(userId, since) {
return this.ctx.storage.sql
.exec(
"SELECT * FROM messages WHERE user_id = ? AND created_at > ? ORDER BY created_at",
userId,
since,
)
.toArray();
}
}

Understand how input and output gates work

While Durable Objects are single-threaded, JavaScript's async/await can allow multiple requests to interleave execution while a request waits for the result of an asynchronous operation. Cloudflare's runtime uses input gates and output gates to prevent data races and ensure correctness by default.

Input gates block new events (incoming requests, fetch responses) while synchronous execution or storage operations are in progress. This prevents concurrent requests from interleaving during critical storage operations:

index.js
import { DurableObject } from "cloudflare:workers";
export class Counter extends DurableObject {
// This code is safe due to input gates
async increment() {
// While these storage operations execute, no other requests
// can interleave - input gate blocks new events
const value = (await this.ctx.storage.get("count")) ?? 0;
await this.ctx.storage.put("count", value + 1);
return value + 1;
}
}

Output gates hold outgoing network messages (responses, fetch requests) until pending storage writes complete. This ensures clients never see confirmation of data that has not been persisted:

index.js
import { DurableObject } from "cloudflare:workers";
export class ChatRoom extends DurableObject {
async sendMessage(userId, content) {
// Write to storage - don't need to await for correctness
this.ctx.storage.sql.exec(
"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
userId,
content,
Date.now(),
);
// This response is held by the output gate until the write completes.
// The client only receives "Message sent" after data is safely persisted.
return "Message sent";
}
}

Write coalescing: Multiple storage writes without intervening await calls are automatically batched into a single atomic implicit transaction:

index.js
import { DurableObject } from "cloudflare:workers";
export class Account extends DurableObject {
async transfer(fromId, toId, amount) {
// ✅ Good: These writes are coalesced into one atomic transaction
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, created_at) VALUES (?, ?, ?, ?)",
fromId,
toId,
amount,
Date.now(),
);
// All three writes commit together atomically
}
// 🔴 Bad: await on KV operations breaks coalescing
async transferBrokenKV(fromId, toId, amount) {
const fromBalance = (await this.ctx.storage.get(`balance:${fromId}`)) ?? 0;
await this.ctx.storage.put(`balance:${fromId}`, fromBalance - amount);
// If the next write fails, the debit already committed!
const toBalance = (await this.ctx.storage.get(`balance:${toId}`)) ?? 0;
await this.ctx.storage.put(`balance:${toId}`, toBalance + amount);
}
}

For more details, see Durable Objects: Easy, Fast, Correct — Choose three and the glossary.

Avoid race conditions with non-storage I/O

Input gates only protect during storage operations. Non-storage I/O like fetch() or writing to R2 allows other requests to interleave, which can cause race conditions:

index.js
import { DurableObject } from "cloudflare:workers";
export class Processor extends DurableObject {
// ⚠️ Potential race condition: fetch() allows interleaving
async processItem(id) {
const item = await this.ctx.storage.get(`item:${id}`);
if (item?.status === "pending") {
// During this fetch, other requests CAN execute and modify storage
const result = await fetch("https://api.example.com/process");
// Another request may have already processed this item!
await this.ctx.storage.put(`item:${id}`, { status: "completed" });
}
}
}

To handle this, use optimistic locking (check-and-set) patterns: read a version number before the external call, then verify it has not changed before writing.

Use blockConcurrencyWhile() sparingly

The blockConcurrencyWhile() method guarantees that no other events are processed until the provided callback completes, even if the callback performs asynchronous I/O. This is useful for operations that must be atomic, such as state initialization from storage in the constructor:

index.js
import { DurableObject } from "cloudflare:workers";
export class ChatRoom extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
// ✅ Good: Use blockConcurrencyWhile for one-time initialization
ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY,
content TEXT
)
`);
});
}
// 🔴 Bad: Don't use blockConcurrencyWhile on every request
async sendMessageSlow(content) {
await this.ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(
"INSERT INTO messages (content) VALUES (?)",
content,
);
});
// If this takes ~5ms, you're limited to ~200 requests/second
}
// ✅ Good: Let output gates handle consistency
async sendMessageFast(content) {
this.ctx.storage.sql.exec(
"INSERT INTO messages (content) VALUES (?)",
content,
);
// Output gate ensures write completes before response is sent
// Other requests can be processed concurrently
}
}

Because blockConcurrencyWhile() blocks all concurrency unconditionally, it significantly reduces throughput. If each call takes ~5ms, that individual Durable Object is limited to approximately 200 requests/second. Reserve it for initialization and migrations, not regular request handling. For normal operations, rely on input/output gates and write coalescing instead.

For atomic read-modify-write operations during request handling, prefer transaction() over blockConcurrencyWhile(). Transactions provide atomicity for storage operations without blocking unrelated concurrent requests.

Communication and API design

Use RPC methods instead of the fetch() handler

Projects with a compatibility date of 2024-04-03 or later should use RPC methods. RPC is more ergonomic, provides better type safety, and eliminates manual request/response parsing.

Define public methods on your Durable Object class, and call them directly from stubs with full TypeScript support:

index.js
import { DurableObject } from "cloudflare:workers";
export class ChatRoom extends DurableObject {
// Public methods are automatically exposed as RPC endpoints
async sendMessage(userId, content) {
const createdAt = Date.now();
const result = this.ctx.storage.sql.exec(
"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?) RETURNING id",
userId,
content,
createdAt,
);
const { id } = result.one();
return { id, userId, content, createdAt };
}
async getMessages(limit = 50) {
const cursor = this.ctx.storage.sql.exec(
"SELECT * FROM messages ORDER BY created_at DESC LIMIT ?",
limit,
);
return cursor.toArray().map((row) => ({
id: row.id,
userId: row.user_id,
content: row.content,
createdAt: row.created_at,
}));
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const roomId = url.searchParams.get("room") ?? "lobby";
const id = env.CHAT_ROOM.idFromName(roomId);
// stub is typed as DurableObjectStub<ChatRoom>
const stub = env.CHAT_ROOM.get(id);
if (request.method === "POST") {
const { userId, content } = await request.json();
// Direct method call with full type checking
const message = await stub.sendMessage(userId, content);
return Response.json(message);
}
// TypeScript knows getMessages() returns Promise<Message[]>
const messages = await stub.getMessages(100);
return Response.json(messages);
},
};

Refer to Invoke methods for more details on RPC and the legacy fetch() handler.

Initialize Durable Objects explicitly with an init() method

Durable Objects do not know their own name or ID from within. If your Durable Object needs to know its identity (for example, to store a reference to itself or to communicate with related objects), you must explicitly initialize it.

index.js
import { DurableObject } from "cloudflare:workers";
export class ChatRoom extends DurableObject {
roomId = null;
// Call this after creating the Durable Object for the first time
async init(roomId, createdBy) {
// Check if already initialized
const existing = await this.ctx.storage.get("roomId");
if (existing) {
return; // Already initialized
}
// Store the identity
await this.ctx.storage.put("roomId", roomId);
await this.ctx.storage.put("createdBy", createdBy);
await this.ctx.storage.put("createdAt", Date.now());
// Cache in memory for this session
this.roomId = roomId;
}
async getRoomId() {
if (this.roomId) {
return this.roomId;
}
const stored = await this.ctx.storage.get("roomId");
if (!stored) {
throw new Error("ChatRoom not initialized. Call init() first.");
}
this.roomId = stored;
return stored;
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const roomId = url.searchParams.get("room") ?? "lobby";
const id = env.CHAT_ROOM.idFromName(roomId);
const stub = env.CHAT_ROOM.get(id);
// Initialize on first access
await stub.init(roomId, "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.js
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 });
},
};

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.js
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
}
}

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.js
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) {
console.log(`WebSocket closed: ${code} ${reason}`);
}
// Called when a WebSocket error occurs
async webSocketError(ws, error) {
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.

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.js
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) {
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);
}
}
}
}

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.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() {
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.
await this.calculateFinalScores();
// Schedule the next alarm only if there's more work to do
// In this case, schedule cleanup in 24 hours
await this.ctx.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000);
}
async calculateFinalScores() {
// Game ending logic
}
}

Make alarm handlers idempotent

In rare cases, alarms may fire more than once. Your alarm() handler should be safe to run multiple times without causing issues.

index.js
import { DurableObject } from "cloudflare:workers";
export class Subscription extends DurableObject {
async alarm() {
// ✅ Good: Check state before performing the action
const lastRenewal = await this.ctx.storage.get("lastRenewal");
const renewalPeriod = 30 * 24 * 60 * 60 * 1000; // 30 days
// If we already renewed recently, don't do it again
if (lastRenewal && Date.now() - lastRenewal < renewalPeriod - 60000) {
console.log("Already renewed recently, skipping");
return;
}
// Perform the renewal
const success = await this.processRenewal();
if (success) {
// Record the renewal time
await this.ctx.storage.put("lastRenewal", Date.now());
// Schedule the next renewal
await this.ctx.storage.setAlarm(Date.now() + renewalPeriod);
} else {
// Retry in 1 hour
await this.ctx.storage.setAlarm(Date.now() + 60 * 60 * 1000);
}
}
async processRenewal() {
// Payment processing logic
return true;
}
}

Clean up storage with deleteAll()

To fully clear a Durable Object's storage, call deleteAll(). Simply deleting individual keys or dropping tables is not sufficient, as some internal metadata may remain. If you have alarms set, delete those first.

index.js
import { DurableObject } from "cloudflare:workers";
export class ChatRoom extends DurableObject {
async clearStorage() {
// If you have an alarm set, delete it first
await this.ctx.storage.deleteAlarm();
// Delete all storage
await this.ctx.storage.deleteAll();
// The Durable Object instance still exists, but with empty storage
// A subsequent request will find no data
}
}

Anti-patterns to avoid

Do not use a single Durable Object as a global singleton

A single Durable Object handling all traffic becomes a bottleneck. Durable Objects execute single-threaded, so all requests to one instance are processed sequentially.

A common mistake is using a Durable Object for global rate limiting or global counters. This funnels all traffic through a single instance:

index.js
import { DurableObject } from "cloudflare:workers";
// 🔴 Bad: Global rate limiter - ALL requests go through one instance
export class RateLimiter extends DurableObject {
async checkLimit(ip) {
const key = `rate:${ip}`;
const count = (await this.ctx.storage.get(key)) ?? 0;
await this.ctx.storage.put(key, count + 1);
return count < 100;
}
}
// 🔴 Bad: Always using the same ID creates a global bottleneck
export default {
async fetch(request, env) {
// Every single request to your application goes through this one DO
const limiter = env.RATE_LIMITER.get(env.RATE_LIMITER.idFromName("global"));
const ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
const allowed = await limiter.checkLimit(ip);
if (!allowed) {
return new Response("Rate limited", { status: 429 });
}
return new Response("OK");
},
};

This pattern does not scale. As traffic increases, the single Durable Object becomes a chokepoint. Instead, identify natural coordination boundaries in your application (per user, per room, per document) and create separate Durable Objects for each.

Avoid blocking the Durable Object with long-running operations

Durable Objects are single-threaded per instance. While one request is being processed, all other requests to that instance wait. Long-running CPU work or slow external API calls block all other requests.

For heavy workloads, consider offloading to Queues or Workflows:

index.js
import { DurableObject } from "cloudflare:workers";
export class ChatRoom extends DurableObject {
// 🔴 Potentially problematic: Slow external call blocks all other requests
async processMessageSlow(content) {
// This 5-second API call blocks all other requests to this room
const analysis = await fetch("https://slow-api.example.com/analyze", {
method: "POST",
body: content,
});
return analysis.json();
}
// ✅ Better: Queue the work and return immediately
async processMessageFast(messageId, content) {
// Store the message
this.ctx.storage.sql.exec(
"INSERT INTO messages (id, content, status) VALUES (?, ?, ?)",
messageId,
content,
"pending",
);
// Queue the heavy processing
await this.env.PROCESSING_QUEUE.send({
messageId,
content,
roomId: await this.ctx.storage.get("roomId"),
});
// Return immediately - other requests aren't blocked
return { messageId, status: "queued" };
}
}

This is not a hard rule. Short external calls are fine. Consider offloading when operations consistently take more than a few hundred milliseconds. Streaming external API responses is another strategy to avoid blocking.

Testing and migrations

Test with Vitest and plan for class migrations

Use @cloudflare/vitest-pool-workers for testing Durable Objects. The integration provides isolated storage per test and utilities for direct instance access.

test/chat-room.test.js
import {
env,
runInDurableObject,
runDurableObjectAlarm,
} from "cloudflare:test";
import { describe, it, expect } from "vitest";
describe("ChatRoom", () => {
// Each test gets isolated storage automatically
it("should send and retrieve messages", async () => {
const id = env.CHAT_ROOM.idFromName("test-room");
const stub = env.CHAT_ROOM.get(id);
// Call RPC methods directly on the stub
await stub.sendMessage("user-1", "Hello!");
await stub.sendMessage("user-2", "Hi there!");
const messages = await stub.getMessages(10);
expect(messages).toHaveLength(2);
});
it("can access instance internals and trigger alarms", async () => {
const id = env.CHAT_ROOM.idFromName("test-room");
const stub = env.CHAT_ROOM.get(id);
// Access storage directly for verification
await runInDurableObject(stub, async (instance, state) => {
const count = state.storage.sql
.exec("SELECT COUNT(*) as count FROM messages")
.one();
expect(count.count).toBe(0); // Fresh instance due to test isolation
});
// Trigger alarms immediately without waiting
const alarmRan = await runDurableObjectAlarm(stub);
expect(alarmRan).toBe(false); // No alarm was scheduled
});
});

Configure Vitest in your vitest.config.ts:

TypeScript
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: "./wrangler.toml" },
},
},
},
});

For schema changes, run migrations in the constructor using blockConcurrencyWhile(). For class renames or deletions, use Wrangler migrations:

{
"migrations": [
// Rename a class
{ "tag": "v2", "renamed_classes": [{ "from": "OldChatRoom", "to": "ChatRoom" }] },
// Delete a class (removes all data!)
{ "tag": "v3", "deleted_classes": ["DeprecatedRoom"] }
]
}

Refer to Durable Objects migrations for more details on class migrations, and Testing with Durable Objects for comprehensive testing patterns including SQLite queries and alarm testing.