Durable Objects は、ステートフルで調整が必要なアプリケーションを作るための強力なプリミティブです。各 Durable Object は単一スレッドで、グローバルに一意なインスタンスであり、独自の永続ストレージを持ちます。これらの性質に合わせて設計することが、効果的なアプリケーションを作るうえで重要です。
このガイドでは、より効果的で正しい Durable Object アプリケーションの作り方を説明します。
Workers はステートレスな関数です。各リクエストは別のインスタンス、別の場所で動き、リクエスト間で共有メモリはありません。Durable Objects はステートフルな計算です。各インスタンスは一意の識別子を持ち、1 か所で動き、リクエストをまたいで状態を保持します。
次が必要なときは Durable Objects を使います。
- 調整 — 複数クライアントが共有状態とやり取りする(チャットルーム、マルチプレイヤーゲーム、共同編集ドキュメント)
- 強い一貫性 — 競合を避けるため操作を直列化する必要がある(在庫管理、予約システム、ターン制ゲーム)
- エンティティごとのストレージ — ユーザー、テナント、リソースごとに分離したデータベースが必要(マルチテナント SaaS、ユーザーごとのデータ)
- 永続的な接続 — リクエストをまたいで残る長寿命の WebSocket 接続(リアルタイム通知、ライブ更新)
- エンティティごとのスケジュール処理 — エンティティごとにタイマーやスケジュールタスクが必要(サブスクリプション更新、ゲームのタイムアウト)
次が必要なときは通常の Workers を使います。
- ステートレスなリクエスト処理 — 共有状態のない API エンドポイント、プロキシ、変換
- 最大のグローバル分散 — 最も近いエッジロケーションでリクエストを処理したい
- 高いファンアウト — 各リクエストが独立していて並列処理できる
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,
});
},
};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 Object が何を表すかを決めることです。調整が必要な論理単位ごとに 1 つの Durable Object を作ります。チャットルーム、ゲームセッション、ドキュメント、ユーザーデータ、テナントのワークスペースなどです。
これが Durable Objects の強さの核心です。ロック付きの共有データベースではなく、アプリケーションの各「原子」が、専用ストレージを持つ単一スレッドの実行環境を持ちます。
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");
},
};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 は作らないでください。
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");
},
};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)一貫したルーティングには、意味のある決定的な文字列で getByName() を使います。同じ入力は常に同じ Durable Object ID を生成するため、同じ論理エンティティへのリクエストは常に同じインスタンスに届きます。
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");
},
};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() は、新しいランダムなインスタンスが必要で、対応関係を外部に保存する場合にだけ使います。
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() });
},
};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 を分けます。親は子の調整と追跡を行い、子は各自の状態を独立して扱います。
これにより並列性が得られます。異なる子への操作は同時に進められ、各子は単一スレッドの一貫性を保ちます(このパターンの詳細)。
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,
);
}
}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 は最初のリクエストを受けた場所の近くに作られます。ほとんどのアプリケーションではこれで十分です。必要ならロケーションヒントを渡し、作成場所に影響を与えられます。
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");
},
};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");
},
};ロケーションヒントは提案であり、保証ではありません。利用できるリージョンと詳細は データの所在地 を参照してください。
新しい 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 を使います。
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();
}
}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() を使い、リクエスト処理の前にマイグレーションと状態初期化を実行します。スキーマの準備が整い、初期化中の競合を防げます。
本番アプリケーションでは、バージョン追跡と実行を自動で行うマイグレーションライブラリを使います。
durable-utils↗ — 実行済みマイグレーションをメモリとストレージの両方で追跡するSQLSchemaMigrationsクラスを提供します。@cloudflare/actorsのストレージユーティリティ ↗ — Cloudflare Actors フレームワークと同じパターンの参照実装です。
ライブラリを使わない場合は、_sql_schema_migrations テーブルでスキーマバージョンを手動追跡できます。次の例がその方法です。
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);
`);
}
}
}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 ストレージに永続化してください。
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
}
}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
}
}通常のデータベースと同じく、よく絞り込む列にインデックスを付けると読み取り性能が大きく上がります。代償は、ストレージが少し増え、書き込みがわずかに遅くなることです。
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();
}
}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 ストレージメソッドなどの非同期操作を待つと入力ゲートが開き、他のリクエストがインターリーブできます。ただし、ストレージ操作には特別な保護があります。
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;
}
}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 リクエスト)を保持します。永続化されていないデータの完了通知を、クライアントが見ないようにするためです。
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";
}
}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 つの原子的な暗黙トランザクションにまとめられます。
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);
}
}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 ↗ と 用語集 を参照してください。
入力ゲートが保護するのはストレージ操作のあいだだけです。fetch() や R2 への書き込みなど、ストレージ以外の I/O ではほかのリクエストがインターリーブでき、競合状態が起きることがあります。
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" });
}
}
}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() メソッドは、コールバックが完了するまでほかのイベントが処理されないことを保証します。コールバックが非同期 I/O を行っても同様です。ストレージからの状態初期化など、原子的でなければならない操作(コンストラクター内)に向いています。
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
}
}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() を優先します。トランザクションは、無関係な同時リクエストをブロックせずに、ストレージ操作の原子性を提供します。
互換日付 が 2024-04-03 以降のプロジェクトでは、RPC メソッドを使います。RPC のほうが扱いやすく、型安全性も高く、リクエスト/レスポンスの手動パースが不要です。
Durable Object クラスに公開メソッドを定義し、スタブから直接呼びます。TypeScript の型サポートもそのまま使えます。
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);
},
};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() ハンドラーの詳細は メソッドの呼び出し を参照してください。
Durable Objects は内部から自分の名前や ID を知りません。アイデンティティが必要な場合(自分自身への参照を保存する、関連オブジェクトと通信するなど)は、明示的に初期化してください。
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`);
},
};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`);
},
};Durable Object スタブのメソッドを呼ぶときは、必ず await します。await しない呼び出しは宙ぶらりんの Promise になり、エラーが握りつぶされ、戻り値も失われます。
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 });
},
};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 で囲み、適切に処理してください。
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
}
}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 プロパティが付き、再試行できるかが分かります。一時的な失敗では、指数バックオフを実装してシステムを過負荷にしないようにします。
エラープロパティ、再試行戦略、指数バックオフの詳細は エラー処理 を参照してください。
Hibernatable WebSockets API を使うと、WebSocket 接続を保ったまま Durable Objects をスリープできます。アイドル接続が多いアプリケーションでは、コストを大きく下げられます。
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);
}
}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 イベント向けに
webSocketError、webSocketMessage、webSocketCloseハンドラーを公開します。 web_socket_auto_reply_to_close互換フラグ(互換日付が2026-04-07以降ではデフォルトで有効)があると、ランタイムが close ハンドシェイクを自動完了します。webSocketCloseでws.close()を呼ぶのは安全ですが、必須ではなくなりました。古い互換日付では、1006の異常クローズを避けるため 必ずws.close()を呼んでください。
詳細は WebSockets を参照してください。
WebSocket の attachment を使うと、ハイバネーションをまたいで残る接続ごとのメタデータを保存できます。ユーザー ID、セッショントークン、その他の接続単位データに使います。
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);
}
}
}
}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 の呼び出しごとにコストがかかります
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
}
}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() ハンドラーは、何度実行しても問題が起きないようにしてください。
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;
}
}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;
}
}Durable Object のストレージを完全に空にするには、deleteAll() を呼びます。個別キーの削除やテーブルのドロップだけでは不十分で、内部メタデータが残ることがあります。互換日付が 2026-02-24 より前で、アラームが設定されている Workers では、先に deleteAlarm() でアラームを削除してください。
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
}
}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 で処理すると、ボトルネックになります。非同期操作ではリクエストのインターリーブができますが、同期 JavaScript の実行は単一スレッドで、ストレージ操作の直列化保証がスループットを制限します。
よくある誤りは、グローバルなレート制限やグローバルカウンターに Durable Object を使うことです。すべてのトラフィックが 1 インスタンスに集まります。
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");
},
};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 を作ります。
Durable Objects のテストには @cloudflare/vitest-plugin を使います。この連携は、インスタンスへ直接アクセスするユーティリティを提供します。
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
});
});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 を組み合わせるときに役立ちます。