Skip to content

非公式本サイトは非公式の日本語ドキュメントであり、Cloudflare 公式サイトではありません。最新情報はdevelopers.cloudflare.comをご確認ください。

Durable Objects のルール

最終更新 Markdown で表示Agent セットアップ

Durable Objects は、ステートフルで調整が必要なアプリケーションを作るための強力なプリミティブです。各 Durable Object は単一スレッドで、グローバルに一意なインスタンスであり、独自の永続ストレージを持ちます。これらの性質に合わせて設計することが、効果的なアプリケーションを作るうえで重要です。

このガイドでは、より効果的で正しい Durable Object アプリケーションの作り方を説明します。

Durable Objects を使うタイミング

ステートフルな調整に使い、ステートレスなリクエスト処理には使わない

Workers はステートレスな関数です。各リクエストは別のインスタンス、別の場所で動き、リクエスト間で共有メモリはありません。Durable Objects はステートフルな計算です。各インスタンスは一意の識別子を持ち、1 か所で動き、リクエストをまたいで状態を保持します。

次が必要なときは Durable Objects を使います。

  • 調整 — 複数クライアントが共有状態とやり取りする(チャットルーム、マルチプレイヤーゲーム、共同編集ドキュメント)
  • 強い一貫性 — 競合を避けるため操作を直列化する必要がある(在庫管理、予約システム、ターン制ゲーム)
  • エンティティごとのストレージ — ユーザー、テナント、リソースごとに分離したデータベースが必要(マルチテナント SaaS、ユーザーごとのデータ)
  • 永続的な接続 — リクエストをまたいで残る長寿命の WebSocket 接続(リアルタイム通知、ライブ更新)
  • エンティティごとのスケジュール処理 — エンティティごとにタイマーやスケジュールタスクが必要(サブスクリプション更新、ゲームのタイムアウト)

次が必要なときは通常の Workers を使います。

  • ステートレスなリクエスト処理 — 共有状態のない API エンドポイント、プロキシ、変換
  • 最大のグローバル分散 — 最も近いエッジロケーションでリクエストを処理したい
  • 高いファンアウト — 各リクエストが独立していて並列処理できる
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,
    	});
    },
};

よくあるパターンは、Workers をステートレスな入口にし、調整が必要なときだけ Durable Objects へリクエストを振り分けることです。Worker は認証、検証、レスポンス整形を担当し、Durable Object はステートフルなロジックを担当します。

設計とシャーディング

調整の「原子」に合わせて Durable Objects をモデル化する

いちばん重要な設計判断は、各 Durable Object が何を表すかを決めることです。調整が必要な論理単位ごとに 1 つの Durable Object を作ります。チャットルーム、ゲームセッション、ドキュメント、ユーザーデータ、テナントのワークスペースなどです。

これが Durable Objects の強さの核心です。ロック付きの共有データベースではなく、アプリケーションの各「原子」が、専用ストレージを持つ単一スレッドの実行環境を持ちます。

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");
	},
};

すべてのリクエストを処理する単一の「グローバル」Durable Object は作らないでください。

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");
    },
};

メッセージスループットの上限

1 つの Durable Object は、単純な操作ならおよそ 500〜1,000 リクエスト/秒 を処理できます。この上限は、リクエストあたりの処理量によって変わります。

操作の種類 スループット
単純なパススルー(パースは最小) 約 1,000 req/sec
中程度の処理(JSON パース、検証) 約 500〜750 req/sec
複雑な操作(変換、ストレージ書き込み) 約 200〜500 req/sec

「原子」を決めるときは、想定リクエストレートも考慮してください。これらの上限を超える場合は、複数の Durable Objects にワークロードをシャーディングします。

たとえば、同時接続 50,000 人のプレイヤーが毎秒 10 回更新するリアルタイムゲームでは、合計 500,000 リクエスト/秒になります。必要なのは 500〜1,000 個のゲームセッション Durable Objects であり、1 つのグローバルコーディネーターではありません。

シャーディング要件は次で計算します。


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

予測可能なルーティングには決定的な ID を使う

一貫したルーティングには、意味のある決定的な文字列で getByName() を使います。同じ入力は常に同じ Durable Object ID を生成するため、同じ論理エンティティへのリクエストは常に同じインスタンスに届きます。

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");
	},
};

スタブを作っても、Durable Object はインスタンス化も起床もしません。スタブのメソッドを呼んだときにだけ、Durable Object が起動します。

newUniqueId() は、新しいランダムなインスタンスが必要で、対応関係を外部に保存する場合にだけ使います。

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() });
    },
};

関連エンティティには親子関係を使う

データをすべて 1 つの Durable Object に置かないでください。階層データ(プロジェクトを含むワークスペース、マッチを管理するゲームサーバー)があるときは、エンティティごとに子 Durable Object を分けます。親は子の調整と追跡を行い、子は各自の状態を独立して扱います。

これにより並列性が得られます。異なる子への操作は同時に進められ、各子は単一スレッドの一貫性を保ちます(このパターンの詳細)。

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

このパターンでは次のようになります。

  • マッチ一覧は親だけを照会する(子はハイバネーションのまま)
  • 異なるマッチはプレイヤー操作を並列に処理する
  • 各マッチはプレイヤーデータ用の独自 SQLite データベースを持つ

レイテンシが重要なアプリではロケーションヒントを検討する

デフォルトでは、Durable Object は最初のリクエストを受けた場所の近くに作られます。ほとんどのアプリケーションではこれで十分です。必要ならロケーションヒントを渡し、作成場所に影響を与えられます。

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");
    },
};

ロケーションヒントは提案であり、保証ではありません。利用できるリージョンと詳細は データの所在地 を参照してください。

ストレージと状態

SQLite バックエンドの Durable Objects を使う

新しい Durable Objects では、SQLite ストレージ が推奨バックエンドです。リレーショナルクエリ、インデックス、トランザクション向けの使い慣れた SQL API を提供し、レガシーのキー値ストレージバックエンドより性能も良くなります。SQLite Durable Objects は、同期版と非同期版の KV API にも対応しています。

Wrangler 設定で、Durable Object クラスに SQLite ストレージを指定します。

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

その後、Durable Object 内で SQL API を使います。

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

SQL API の詳細は Durable Objects ストレージへのアクセス を参照してください。

コンストラクターでストレージを初期化し、マイグレーションを実行する

コンストラクターで blockConcurrencyWhile() を使い、リクエスト処理の前にマイグレーションと状態初期化を実行します。スキーマの準備が整い、初期化中の競合を防げます。

本番アプリケーションでは、バージョン追跡と実行を自動で行うマイグレーションライブラリを使います。

ライブラリを使わない場合は、_sql_schema_migrations テーブルでスキーマバージョンを手動追跡できます。次の例がその方法です。

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

インメモリ状態と永続ストレージの違いを理解する

Durable Objects には、特性の異なる複数の状態管理レイヤーがあります。

種類 速度 永続性 用途
インメモリ(クラスプロパティ) 最速 エビクションまたはクラッシュで失われる キャッシュ、アクティブな接続
SQLite ストレージ 速い 再起動をまたいで残る 主なデータ保存
外部(R2、D1) 可変 耐久性があり、DO をまたいでアクセスできる 大きなファイル、共有データ

インメモリ状態は、非アクティブで Durable Object がメモリからエビクションされた場合や、未捕捉例外でクラッシュした場合には 保持されません。重要な状態は必ず SQLite ストレージに永続化してください。

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

よく照会する列にインデックスを作る

通常のデータベースと同じく、よく絞り込む列にインデックスを付けると読み取り性能が大きく上がります。代償は、ストレージが少し増え、書き込みがわずかに遅くなることです。

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

入力ゲートと出力ゲートの仕組みを理解する

Durable Objects は単一スレッドですが、JavaScript の async/await により、非同期操作の結果待ちのあいだに複数リクエストの実行がインターリーブすることがあります。Cloudflare のランタイムは 入力ゲート出力ゲート を使い、データ競合を防ぎ、デフォルトで正しさを保証します。

入力ゲート は、同期 JavaScript の実行中に新しいイベント(受信リクエスト、fetch レスポンス)をブロックします。fetch() や KV ストレージメソッドなどの非同期操作を待つと入力ゲートが開き、他のリクエストがインターリーブできます。ただし、ストレージ操作には特別な保護があります。

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

出力ゲート は、未完了のストレージ書き込みが終わるまで、外向きのネットワークメッセージ(レスポンス、fetch リクエスト)を保持します。永続化されていないデータの完了通知を、クライアントが見ないようにするためです。

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";
    }
}

書き込みの結合: 間に await を挟まない複数のストレージ書き込みは、自動的に 1 つの原子的な暗黙トランザクションにまとめられます。

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

詳細は Durable Objects: Easy, Fast, Correct — Choose three用語集 を参照してください。

ストレージ以外の I/O での競合状態を避ける

入力ゲートが保護するのはストレージ操作のあいだだけです。fetch() や R2 への書き込みなど、ストレージ以外の I/O ではほかのリクエストがインターリーブでき、競合状態が起きることがあります。

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

対処するには、楽観的ロック(check-and-set)パターンを使います。外部呼び出しの前にバージョン番号を読み、書き込み前に変わっていないことを確認します。

blockConcurrencyWhile() は控えめに使う

blockConcurrencyWhile() メソッドは、コールバックが完了するまでほかのイベントが処理されないことを保証します。コールバックが非同期 I/O を行っても同様です。ストレージからの状態初期化など、原子的でなければならない操作(コンストラクター内)に向いています。

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

blockConcurrencyWhile() は並行性を無条件にすべてブロックするため、スループットが大きく下がります。各呼び出しが約 5ms だと、その Durable Object はおよそ 200 リクエスト/秒に制限されます。初期化とマイグレーションに使い、通常のリクエスト処理には使わないでください。通常操作では、入力/出力ゲートと書き込みの結合に頼ります。

リクエスト処理中の原子的な読み取り・変更・書き込みには、blockConcurrencyWhile() より transaction() を優先します。トランザクションは、無関係な同時リクエストをブロックせずに、ストレージ操作の原子性を提供します。

通信と API 設計

fetch() ハンドラーではなく RPC メソッドを使う

互換日付2024-04-03 以降のプロジェクトでは、RPC メソッドを使います。RPC のほうが扱いやすく、型安全性も高く、リクエスト/レスポンスの手動パースが不要です。

Durable Object クラスに公開メソッドを定義し、スタブから直接呼びます。TypeScript の型サポートもそのまま使えます。

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

RPC とレガシーの fetch() ハンドラーの詳細は メソッドの呼び出し を参照してください。

init() メソッドで Durable Objects を明示的に初期化する

Durable Objects は内部から自分の名前や ID を知りません。アイデンティティが必要な場合(自分自身への参照を保存する、関連オブジェクトと通信するなど)は、明示的に初期化してください。

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`);
	},
};

RPC 呼び出しは必ず await する

Durable Object スタブのメソッドを呼ぶときは、必ず await します。await しない呼び出しは宙ぶらりんの Promise になり、エラーが握りつぶされ、戻り値も失われます。

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

エラー処理

エラーを処理し、例外境界を使う

Durable Object の未捕捉例外は、未知の状態を残し、ランタイムがインスタンスを終了することがあります。リスクのある操作は try...catch で囲み、適切に処理してください。

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

Worker から Durable Objects を呼ぶとき、エラーに .retryable.overloaded プロパティが付き、再試行できるかが分かります。一時的な失敗では、指数バックオフを実装してシステムを過負荷にしないようにします。

エラープロパティ、再試行戦略、指数バックオフの詳細は エラー処理 を参照してください。

WebSocket とリアルタイム

コスト効率のため Hibernatable WebSockets API を使う

Hibernatable WebSockets API を使うと、WebSocket 接続を保ったまま Durable Objects をスリープできます。アイドル接続が多いアプリケーションでは、コストを大きく下げられます。

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

ハイバネーション API では、アクティブな JavaScript 実行がないときに Durable Object はスリープできますが、WebSocket 接続は開いたままです。メッセージが届くと、Durable Object は自動で起きます。

推奨事項:

  • WebSocket Hibernation API は、それぞれの WebSocket イベント向けに webSocketErrorwebSocketMessagewebSocketClose ハンドラーを公開します。
  • web_socket_auto_reply_to_close 互換フラグ(互換日付が 2026-04-07 以降ではデフォルトで有効)があると、ランタイムが close ハンドシェイクを自動完了します。webSocketClosews.close() を呼ぶのは安全ですが、必須ではなくなりました。古い互換日付では、1006 の異常クローズを避けるため 必ず ws.close() を呼んでください。

詳細は WebSockets を参照してください。

serializeAttachment() で接続ごとの状態を永続化する

WebSocket の attachment を使うと、ハイバネーションをまたいで残る接続ごとのメタデータを保存できます。ユーザー ID、セッショントークン、その他の接続単位データに使います。

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

スケジュールとライフサイクル

エンティティごとのスケジュールタスクにはアラームを使う

各 Durable Object は Alarms API で将来の作業をスケジュールでき、受信リクエスト、RPC 呼び出し、WebSocket メッセージなしで、任意の間隔でバックグラウンドタスクを実行できます。

alarms の要点:

  • setAlarm(timestamp) は、将来の任意の時点(ミリ秒精度)で alarm() ハンドラーを実行するようスケジュールします
  • Alarms は自動では繰り返されません — 次の実行をスケジュールするには、もう一度 setAlarm() を呼ぶ必要があります
  • 作業があるときだけ alarm をスケジュールする — 短い間隔(秒単位)ですべての Durable Object を起こすのは避けてください。alarm の呼び出しごとにコストがかかります
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
    }
}

アラームハンドラーはべき等にする

まれに、アラームが複数回発火することがあります。alarm() ハンドラーは、何度実行しても問題が起きないようにしてください。

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

deleteAll() でストレージを掃除する

Durable Object のストレージを完全に空にするには、deleteAll() を呼びます。個別キーの削除やテーブルのドロップだけでは不十分で、内部メタデータが残ることがあります。互換日付が 2026-02-24 より前で、アラームが設定されている Workers では、先に 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
    }
}

予期しないシャットダウンに備えて設計する

Durable Objects は、デプロイ、非アクティブ、ランタイムの判断により、いつでもシャットダウンすることがあります。シャットダウンフック(提供されていません)に頼るのではなく、状態を少しずつ書き込むようにアプリケーションを設計してください。

シャットダウン前に実行されるシャットダウンフックやライフサイクルコールバックは提供していません。Cloudflare は、こうしたフックがすべてのケースで実行されることを保証できないためです。また、外部ソフトウェアがこうした(信頼性のない)フックに過度に依存するおそれもあります。

シャットダウンフックに頼る代わりに、ストレージへ定期的に書き込めば、シャットダウンから安全に回復できます。

たとえば、データのストリームを処理して進捗を保存する必要がある場合は、最後にまとめて永続化するのではなく、処理の途中で位置をストレージに書き込みます。

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

こうした書き方は直感に反するように感じるかもしれませんが、Durable Object のストレージ書き込みは高速で同期的なので、パフォーマンスを大きく気にせず状態を永続化できます。

この方法なら、Durable Object が予期せずシャットダウンしても、任意の地点から安全に再開できます。

避けるべきアンチパターン

1 つの Durable Object をグローバルシングルトンにしない

すべてのトラフィックを 1 つの Durable Object で処理すると、ボトルネックになります。非同期操作ではリクエストのインターリーブができますが、同期 JavaScript の実行は単一スレッドで、ストレージ操作の直列化保証がスループットを制限します。

よくある誤りは、グローバルなレート制限やグローバルカウンターに Durable Object を使うことです。すべてのトラフィックが 1 インスタンスに集まります。

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");
	},
};

このパターンはスケールしません。トラフィックが増えると、1 つの Durable Object が隘路になります。代わりに、アプリケーションの自然な調整境界(ユーザーごと、ルームごと、ドキュメントごと)を見極め、それぞれに別の Durable Object を作ります。

テストとクラスのライフサイクル

Vitest でテストし、クラスライフサイクルの変更に備える

Durable Objects のテストには @cloudflare/vitest-plugin を使います。この連携は、インスタンスへ直接アクセスするユーティリティを提供します。

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

vitest.config.ts で Vitest を設定します。

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

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

データスキーマの変更では、コンストラクターで blockConcurrencyWhile() を使ってスキーママイグレーションを実行します。クラスのリネームや削除では、Wrangler 設定ファイルの exports フィールドでクラスエントリを変更します。

{
  "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"

クラスライフサイクル変更の詳細は Durable Object クラスの exports を、SQLite クエリとアラームテストを含む包括的なテストパターンは Durable Objects でのテスト を参照してください。

関連リソース

  • Workers のベストプラクティス: Durable Objects を呼ぶ Workers に適用できる、リクエスト処理、可観測性、セキュリティのコードパターンです。
  • Workflows のルール: 耐久性のある複数ステップ Workflows のベストプラクティスです。長時間のオーケストレーションで Workflows と Durable Objects を組み合わせるときに役立ちます。

役に立ちましたか?