Pular para o conteúdo

Rules of Durable Objects

Atualizado em Ver como Markdown

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.jsjs
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,
		});
	},
};
index.tsts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	BOOKING: DurableObjectNamespace<SeatBooking>;
}

// ✅ 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<Env> {
async bookSeat(
seatId: string,
userId: string
): Promise<{ success: boolean; message: string }> {
// Check if seat is already booked
const existing = this.ctx.storage.sql
.exec<{ user_id: string }>(
"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: Request, env: Env): Promise<Response> {
		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<{
    		seatId: string;
    		userId: string;
    	}>();
    	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.jsjs
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");
	},
};
index.tsts
import { DurableObject } from "cloudflare:workers";

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

// Each chat room is its own Durable Object instance
export class ChatRoom extends DurableObject<Env> {
	async sendMessage(userId: string, message: string) {
		// 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: Request, env: Env): Promise<Response> {
		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.jsjs
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");
	},
};
index.tsts
import { DurableObject } from "cloudflare:workers";

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

// 🔴 Bad: A single Durable Object handling ALL chat rooms
export class ChatRoom extends DurableObject<Env> {
async sendMessage(roomId: string, userId: string, message: string) {
// 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: Request, env: Env): Promise<Response> {
		// 🔴 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");
    },
};

Message throughput limits

A single Durable Object can handle approximately 500-1,000 requests per second for simple operations. This limit varies based on the work performed per request:

Operation type Throughput
Simple pass-through (minimal parsing) ~1,000 req/sec
Moderate processing (JSON parsing, validation) ~500-750 req/sec
Complex operations (transformation, storage writes) ~200-500 req/sec

When modeling your "atom," factor in the expected request rate. If your use case exceeds these limits, shard your workload across multiple Durable Objects.

For example, consider a real-time game with 50,000 concurrent players sending 10 updates per second. This generates 500,000 requests per second total. You would need 500-1,000 game session Durable Objects—not one global coordinator.

Calculate your sharding requirements:


Required DOs = (Total requests/second) / (Requests per DO capacity)

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.jsjs
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");
	},
};
index.tsts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	GAME_SESSION: DurableObjectNamespace<GameSession>;
}

export class GameSession extends DurableObject<Env> {
	async join(playerId: string) {
		// Game logic here
	}
}

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		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.jsjs
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() });
	},
};
index.tsts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	GAME_SESSION: DurableObjectNamespace<GameSession>;
}

export class GameSession extends DurableObject<Env> {
	async join(playerId: string) {
		// Game logic here
	}
}

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		// 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.jsjs
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,
		);
	}
}
index.tsts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	GAME_SERVER: DurableObjectNamespace<GameServer>;
	GAME_MATCH: DurableObjectNamespace<GameMatch>;
}

// Parent: Coordinates matches, but doesn't store match data
export class GameServer extends DurableObject<Env> {
	async createMatch(matchName: string): Promise<string> {
		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(): Promise<{ id: string; name: string }[]> {
		// Parent knows about all matches without waking up each child
		const cursor = this.ctx.storage.sql.exec<{ id: string; name: string }>(
			"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<Env> {
	async init(matchId: string, matchName: string) {
		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: string, playerName: string) {
		this.ctx.storage.sql.exec(
			"INSERT INTO players (id, name, score) VALUES (?, ?, 0)",
			playerId,
			playerName
		);
	}

	async updateScore(playerId: string, score: number) {
		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.jsjs
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);
		const stub = env.GAME_SESSION.get(id, { locationHint: region });

		return new Response("Connected to game session");
	},
};
index.tsts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	GAME_SESSION: DurableObjectNamespace<GameSession>;
}

export class GameSession extends DurableObject<Env> {
	// Game session logic
}

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		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);
    	const stub = env.GAME_SESSION.get(id, { locationHint: region });

    	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"] }
  ]
}
[[migrations]]
tag = "v1"
new_sqlite_classes = [ "ChatRoom" ]

Then use the SQL API in your Durable Object:

index.jsjs
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();
	}
}
index.tsts
import { DurableObject } from "cloudflare:workers";

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

type Message = {
id: number;
user_id: string;
content: string;
created_at: number;
};

export class ChatRoom extends DurableObject<Env> {
	constructor(ctx: DurableObjectState, env: 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: string, content: string) {
    	this.ctx.storage.sql.exec(
    		"INSERT INTO messages (user_id, content, created_at) VALUES (?, ?, ?)",
    		userId,
    		content,
    		Date.now()
    	);
    }

    async getRecentMessages(limit: number = 50): Promise<Message[]> {
    	// Use type parameter for typed results
    	const cursor = this.ctx.storage.sql.exec<Message>(
    		"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.

For production applications, use a migration library that handles version tracking and execution automatically:

If you prefer not to use a library, you can track schema versions manually using a _sql_schema_migrations table. The following example demonstrates this approach:

index.jsjs
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() {
		// Create the migrations tracking table if it does not exist
		this.ctx.storage.sql.exec(`
			CREATE TABLE IF NOT EXISTS _sql_schema_migrations (
				id INTEGER PRIMARY KEY,
				applied_at TEXT NOT NULL DEFAULT (datetime('now'))
			);
		`);

		// Determine the current schema version
		const version = this.ctx.storage.sql
			.exec(
				"SELECT COALESCE(MAX(id), 0) as version FROM _sql_schema_migrations",
			)
			.one().version;

		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);
				INSERT INTO _sql_schema_migrations (id) VALUES (1);
			`);
		}

		if (version < 2) {
			// Future migration: add a new column
			this.ctx.storage.sql.exec(`
				ALTER TABLE messages ADD COLUMN edited_at INTEGER;
				INSERT INTO _sql_schema_migrations (id) VALUES (2);
			`);
		}
	}
}
index.tsts
import { DurableObject } from "cloudflare:workers";

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

export class ChatRoom extends DurableObject<Env> {
	constructor(ctx: DurableObjectState, env: Env) {
		super(ctx, env);

		// blockConcurrencyWhile() ensures no requests are processed until this completes
		ctx.blockConcurrencyWhile(async () => {
			await this.migrate();
		});
	}

	private async migrate() {
		// Create the migrations tracking table if it does not exist
		this.ctx.storage.sql.exec(`
			CREATE TABLE IF NOT EXISTS _sql_schema_migrations (
				id INTEGER PRIMARY KEY,
				applied_at TEXT NOT NULL DEFAULT (datetime('now'))
			);
		`);

		// Determine the current schema version
		const version =
			this.ctx.storage.sql
				.exec<{ version: number }>(
					"SELECT COALESCE(MAX(id), 0) as version FROM _sql_schema_migrations",
				)
				.one().version;

		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);
				INSERT INTO _sql_schema_migrations (id) VALUES (1);
			`);
		}

		if (version < 2) {
			// Future migration: add a new column
			this.ctx.storage.sql.exec(`
				ALTER TABLE messages ADD COLUMN edited_at INTEGER;
				INSERT INTO _sql_schema_migrations (id) VALUES (2);
			`);
		}
	}
}

Understand the difference between in-memory state and persistent storage

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

Type Speed Persistence Use Case
In-memory (class properties) Fastest Lost on eviction or crash Caching, active connections
SQLite storage Fast Durable across restarts Primary data storage
External (R2, D1) Variable Durable, cross-DO accessible Large 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.jsjs
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
	}
}
index.tsts
import { DurableObject } from "cloudflare:workers";

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

type Message = {
id: number;
user_id: string;
content: string;
created_at: number;
};

export class ChatRoom extends DurableObject<Env> {
	// In-memory cache - fast but NOT preserved across evictions or crashes
	private messageCache: Message[] | null = null;

    async getRecentMessages(): Promise<Message[]> {
    	// 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<Message>(
    		"SELECT * FROM messages ORDER BY created_at DESC LIMIT 100"
    	);
    	this.messageCache = cursor.toArray();
    	return this.messageCache;
    }

    async addMessage(userId: string, content: string) {
    	// ✅ 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.jsjs
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();
	}
}
index.tsts
import { DurableObject } from "cloudflare:workers";

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

export class ChatRoom extends DurableObject<Env> {
	constructor(ctx: DurableObjectState, env: 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: string, since: number) {
		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 JavaScript execution is in progress. Awaiting async operations like fetch() or KV storage methods opens the input gate, allowing other requests to interleave. However, storage operations provide special protection:

index.jsjs
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;
	}
}
index.tsts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	COUNTER: DurableObjectNamespace<Counter>;
}

export class Counter extends DurableObject<Env> {
	// This code is safe due to input gates
	async increment(): Promise<number> {
		// While these storage operations execute, no other requests
		// can interleave - input gate blocks new events
		const value = (await this.ctx.storage.get<number>("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.jsjs
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";
	}
}
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<string> {
		// 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.jsjs
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);
	}
}
index.tsts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	ACCOUNT: DurableObjectNamespace<Account>;
}

export class Account extends DurableObject<Env> {
	async transfer(fromId: string, toId: string, amount: number) {
		// ✅ 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: string, toId: string, amount: number) {
		const fromBalance = (await this.ctx.storage.get<number>(`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<number>(`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.jsjs
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" });
		}
	}
}
index.tsts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	PROCESSOR: DurableObjectNamespace<Processor>;
}

export class Processor extends DurableObject<Env> {
	// ⚠️ Potential race condition: fetch() allows interleaving
	async processItem(id: string) {
		const item = await this.ctx.storage.get<{ status: string }>(`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.jsjs
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
	}
}
index.tsts
import { DurableObject } from "cloudflare:workers";

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

export class ChatRoom extends DurableObject<Env> {
	constructor(ctx: DurableObjectState, env: 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: string) {
		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: string) {
		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.jsjs
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);
	},
};
index.tsts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	// Type parameter provides typed method calls on the stub
	CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

type Message = {
id: number;
userId: string;
content: string;
createdAt: number;
};

export class ChatRoom extends DurableObject<Env> {
	// Public methods are automatically exposed as RPC endpoints
	async sendMessage(userId: string, content: string): Promise<Message> {
		const createdAt = Date.now();
		const result = this.ctx.storage.sql.exec<{ id: number }>(
			"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: number = 50): Promise<Message[]> {
    	const cursor = this.ctx.storage.sql.exec<{
    		id: number;
    		user_id: string;
    		content: string;
    		created_at: number;
    	}>("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: Request, env: Env): Promise<Response> {
		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<{
    			userId: string;
    			content: string;
    		}>();
    		// 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.jsjs
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`);
	},
};
index.tsts
import { DurableObject } from "cloudflare:workers";

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

export class ChatRoom extends DurableObject<Env> {
	private roomId: string | null = null;

	// Call this after creating the Durable Object for the first time
	async init(roomId: string, createdBy: string) {
		// 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(): Promise<string> {
		if (this.roomId) {
			return this.roomId;
		}

		const stored = await this.ctx.storage.get<string>("roomId");
		if (!stored) {
			throw new Error("ChatRoom not initialized. Call init() first.");
		}

		this.roomId = stored;
		return stored;
	}
}

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		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.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, 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<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 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
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;
		}

		// 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
	}
}
index.tsts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	GAME_MATCH: DurableObjectNamespace<GameMatch>;
}

export class GameMatch extends DurableObject<Env> {
	async startGame(durationMs: number = 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?: AlarmInvocationInfo) {
    	const isActive = await this.ctx.storage.get<boolean>("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;
    	}

    	// 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);
    }

    private 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.jsjs
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;
	}
}
index.tsts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	SUBSCRIPTION: DurableObjectNamespace<Subscription>;
}

export class Subscription extends DurableObject<Env> {
	async alarm() {
		// ✅ Good: Check state before performing the action
		const lastRenewal = await this.ctx.storage.get<number>("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);
		}
	}

	private async processRenewal(): Promise<boolean> {
		// 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. Workers with a compatibility date before 2026-02-24 and an alarm set should delete the alarm first with deleteAlarm().

index.jsjs
import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
	async clearStorage() {
		// Delete all storage, including any set alarm
		await this.ctx.storage.deleteAll();

		// The Durable Object instance still exists, but with empty storage
		// A subsequent request will find no data
	}
}
index.tsts
import { DurableObject } from "cloudflare:workers";

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

export class ChatRoom extends DurableObject<Env> {
	async clearStorage() {

    	// Delete all storage, including any set alarm
    	await this.ctx.storage.deleteAll();

    	// The Durable Object instance still exists, but with empty storage
    	// A subsequent request will find no data
    }
}

Design for unexpected shutdowns

Durable Objects may shut down at any time due to deployments, inactivity, or runtime decisions. Rather than relying on shutdown hooks (which are not provided), design your application to write state incrementally.

Shutdown hooks or lifecycle callbacks that run before shutdown are not provided because Cloudflare cannot guarantee these hooks would execute in all cases, and external software may rely too heavily on these (unreliable) hooks.

Instead of relying on shutdown hooks, you can regularly write to storage to recover gracefully from shutdowns.

For example, if you are processing a stream of data and need to save your progress, write your position to storage as you go rather than waiting to persist it at the end:

// Good: Write progress as you go
async processData(data) {
  data.forEach(async (item, index) => {
    await this.processItem(item);
    // Save progress frequently
    await this.ctx.storage.put("lastProcessedIndex", index);
  });
}

While this may feel unintuitive, Durable Object storage writes are fast and synchronous, so you can persist state with minimal performance concerns.

This approach ensures your Durable Object can safely resume from any point, even if it shuts down unexpectedly.

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. While async operations allow request interleaving, all synchronous JavaScript execution is single-threaded, and storage operations provide serialization guarantees that limit throughput.

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

index.jsjs
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");
	},
};
index.tsts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	RATE_LIMITER: DurableObjectNamespace<RateLimiter>;
}

// 🔴 Bad: Global rate limiter - ALL requests go through one instance
export class RateLimiter extends DurableObject<Env> {
	async checkLimit(ip: string): Promise<boolean> {
		const key = `rate:${ip}`;
		const count = (await this.ctx.storage.get<number>(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: Request, env: Env): Promise<Response> {
		// 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.

Testing and class lifecycle

Test with Vitest and plan for class lifecycle changes

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

test/chat-room.test.jsjs
import { env } from "cloudflare:workers";
import { runInDurableObject, runDurableObjectAlarm } from "cloudflare:test";
import { describe, it, expect } from "vitest";

describe("ChatRoom", () => {
	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(2);
		});

		// Trigger alarms immediately without waiting
		const alarmRan = await runDurableObjectAlarm(stub);
		expect(alarmRan).toBe(false); // No alarm was scheduled
	});
});
test/chat-room.test.tsts
import { env } from "cloudflare:workers";
import {
	runInDurableObject,
	runDurableObjectAlarm,
} from "cloudflare:test";
import { describe, it, expect } from "vitest";

describe("ChatRoom", () => {

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<{ count: number }>("SELECT COUNT(*) as count FROM messages")
    			.one();
    		expect(count.count).toBe(2);
    	});

    	// 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:

import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
import { defineConfig } from "vitest/config";

export default defineConfig({
	plugins: [
		cloudflareTest({
			wrangler: { configPath: "./wrangler.jsonc" },
		}),
	],
});

For data-schema changes, run schema migrations in the constructor using blockConcurrencyWhile(). For class renames or deletions, change the class entry in the exports field of your Wrangler configuration file:

{
  "exports": {
    // Rename a class — also add a live entry for the new name
    "OldChatRoom": { "type": "durable-object", "state": "renamed", "renamed_to": "ChatRoom" },
    "ChatRoom": { "type": "durable-object", "storage": "sqlite" },
    // Delete a class (removes all data!)
    "DeprecatedRoom": { "type": "durable-object", "state": "deleted" }
  }
}
[exports.OldChatRoom]
type = "durable-object"
state = "renamed"
renamed_to = "ChatRoom"

[exports.ChatRoom]
type = "durable-object"
storage = "sqlite"

[exports.DeprecatedRoom]
type = "durable-object"
state = "deleted"

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

  • Workers Best Practices: code patterns for request handling, observability, and security that apply to the Workers calling your Durable Objects.
  • Rules of Workflows: best practices for durable, multi-step Workflows — useful when combining Workflows with Durable Objects for long-running orchestration.