- Durable Objects(DO)の実行モデル — ID ごとに世界で 1 つのインスタンス、直列実行、強整合ストレージ
DurableObjectクラスの定義と、wrangler.jsonc のdurable_objectsバインディング /exports(旧migrations)設定idFromName/getByNameで特定インスタンスを取得し、RPC メソッドやfetch()を呼び出す方法- SQLite ストレージ API(
sql.exec)と Alarms による状態管理・スケジュール実行 - WebSocket Hibernation API を使ったリアルタイム通信(チャットルーム)の入口
世界に 1 つのインスタンス — DO のメンタルモデル
Durable Objects は「コンピュートとストレージが合体した特別な Worker」です。KV・R2・D1 が「Worker から呼び出す外部ストレージ」だったのに対し、DO は JavaScript クラスのインスタンスそのものが状態を持ち、専用のストレージを抱えたまま Cloudflare のネットワーク上で動きます。核となる保証は次の 3 つです。
- グローバルに一意 — 名前(または ID)ごとに、世界中でただ 1 つのインスタンスだけが存在します。世界のどこから同じ名前でアクセスしても、必ず同じインスタンスに届きます。これが複数クライアント間の「協調ポイント」になります。
- シングルスレッドで直列実行 — 各インスタンスはシングルスレッドで、ストレージ操作中は入力ゲート(input gates)・出力ゲート(output gates)という仕組みが他のイベントの割り込みを防ぎます。読み取り→変更→書き込みを素朴に書いても競合しません。
- 強整合な専用ストレージ — 各インスタンスにはトランザクショナルで強整合な SQLite データベースが同じマシン上に付属します(SQLite バックエンドで 1 オブジェクトあたり最大 10 GB、Free プランはアカウント合計 5 GB まで。2026-08 時点)。他のオブジェクトからはアクセスできません。
これは分散システムでいう Actor モデルの実装です。1 つの DO インスタンス=1 つのアクターが、メッセージ(HTTP/RPC リクエスト)を 1 件ずつ処理し、自分専用の状態を読み書きします。既習のストレージ製品と比べると位置づけが分かります。
| 製品 | 整合性 | アクセス単位 | 得意なこと |
|---|---|---|---|
| KV(第 5 章) | 結果整合 | キー | 読み取り中心の設定・キャッシュ配布 |
| D1(第 7 章) | 単一プライマリの SQL | データベース | アプリ全体のリレーショナルデータ |
| Durable Objects | 強整合(インスタンス内) | オブジェクト(ID ごと) | 協調・カウンタ・チャット・ゲームなどリアルタイムな状態共有 |
ライフサイクルも押さえておきましょう。DO は事前にプロビジョニングするものではなく、最初のアクセス時に暗黙に作られます。
- 最初のリクエスト(またはアラーム)到着時に
constructor()が実行され、続けて呼び出されたメソッドが動く(アクティブ状態) - 処理が終わるとメモリ上でアイドルになり、休止(hibernation)可能な条件を満たしていれば約 10 秒の無活動でメモリから退避される
- 休止するとインメモリ状態は消える。次のイベントで
constructor()から再構築される - 休止できない状態(
setTimeout/setIntervalの予約、標準 API の WebSocket、送信側の接続などがある場合)では 70〜140 秒の無活動後にメモリから完全に退避される
setTimeout や標準 WebSocket が残っていると 70〜140 秒メモリに居座り、その間ずっと課金されます。どちらの経路でも破線のループで再構築が起き、インメモリ状態は消えます。生き残るのは下段のストレージだけなので、真実はそこに置きます。つまりインメモリ状態はいつ消えてもおかしくない前提で、残したいデータは必ずストレージへ書きます。シャットダウンフックは提供されないため、「終了時にまとめて保存」ではなく「処理しながら都度保存」が公式に推奨される設計です。
もう 1 つ重要な制約として、DO はインターネットから直接リクエストを受けません。必ず Worker(または別の DO)がバインディング経由で呼び出します。Worker が玄関、DO が奥の個室というイメージです。
クラス定義・wrangler 設定・呼び出し
DurableObject クラスとバインディング
DO の実体は cloudflare:workers の DurableObject 基底クラスを継承した、ふつうの export クラスです。コンストラクタは ctx(DurableObjectState — ストレージや WebSocket 管理を持つ)と env(バインディング)を受け取り、super(ctx, env) の呼び出しが必須です。public メソッドはそのまま RPC メソッドとして Worker から呼び出せます(compatibility date 2024-04-03 以降)。HTTP の Request/Response をそのまま流したい場合(WebSocket など)は fetch() ハンドラを実装します。
呼び出す側の Worker には、wrangler.jsonc で durable_objects.bindings を宣言します。name が env に生えるプロパティ名、class_name が対象クラスです。
exports 宣言でクラスを登録する(旧 migrations)
バインディングだけではネームスペース(そのクラスの DO 群)は作られません。Worker が export する DO クラスを wrangler.jsonc の exports フィールドで宣言することで、初回デプロイ時に Cloudflare がネームスペースをプロビジョニングします。新規クラスは SQLite ストレージバックエンド("storage": "sqlite")のみ指定できます。
{
"durable_objects": {
"bindings": [
{ "name": "MY_DURABLE_OBJECT", "class_name": "MyDurableObject" }
]
},
"exports": {
"MyDurableObject": { "type": "durable-object", "storage": "sqlite" }
}
}
以前は migrations 配列("migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyDurableObject"] }] のような形式)でクラスの作成・リネーム・削除を管理していました。この旧方式も引き続きサポートされますが、exports とは排他で、両方書くと設定検証で拒否されます。また一度 exports でデプロイした Worker は migrations へ戻せません。公式ドキュメントの例には旧方式のものも残っているので、読み替えられるよう両方の形を覚えておきましょう。クラスの削除・リネーム・別 Worker への移管も exports のエントリ(tombstone)で宣言的に行えます。
ID とスタブ — idFromName / getByName
特定のインスタンスにアクセスするには、まず名前から DurableObjectId(64 桁 16 進数)を作り、そこからスタブ(クライアント)を取得します。getByName() はこの 2 段階をまとめたショートカットです。
// 2 段階: 名前 → ID → スタブ
const id = env.MY_DURABLE_OBJECT.idFromName("foo");
const stub = env.MY_DURABLE_OBJECT.get(id);
// ショートカット(同じインスタンスに届く)
const stub2 = env.MY_DURABLE_OBJECT.getByName("foo");
// public メソッドを RPC として呼び出す
const greeting = await stub2.sayHello();
スタブを作った時点ではまだ何も起きず、メソッドを呼んだ瞬間に初めてリクエストが送られ、必要ならインスタンスが起動します。名前由来の ID は初回アクセス時のみ「世界中で同名のオブジェクトが同時に作られていないか」の確認が入るため、数百 ms 程度の遅延が乗ることがあります(以後はキャッシュされます)。この確認を省ける newUniqueId()(ランダム ID)や、文字列化した ID を復元する idFromString() も用意されています。DO 側からは this.ctx.id.name で自分の名前を参照できます。
SQLite ストレージ API と Alarms
sql.exec とカーソル
SQLite バックエンドのクラスでは、this.ctx.storage.sql.exec(query, ...bindings) で埋め込み SQLite に直接クエリを発行できます。同期 API(await 不要)で、ストレージが同じマシン上にあるためレイテンシは実質ゼロです。戻り値はカーソルで、toArray()(全行をオブジェクト配列で)、one()(ちょうど 1 行であることを検証して返す)、raw()(列名なしの値配列)、for...of での反復をサポートします。? プレースホルダによるパラメータバインドも使えます。
// SQL API(同期実行)
const rows = this.ctx.storage.sql
.exec("SELECT * FROM users WHERE team = ?", teamId)
.toArray();
const count = this.ctx.storage.sql
.exec("SELECT count(*) AS c FROM users")
.one().c; // 結果がちょうど 1 行でなければ例外
// 同期 KV API(SQLite バックエンドのみ)
this.ctx.storage.kv.put("config", { theme: "dark" });
const config = this.ctx.storage.kv.get("config");
SQL を使うまでもない単純な値には、同期 KV API(ctx.storage.kv の get / put / delete / list)や従来の非同期 KV API(ctx.storage.get() / put())も使えます。どのメソッドも暗黙にトランザクションで包まれ、await を挟まない一連の書き込みは自動的にまとめてアトミックにコミットされます。明示的なトランザクションが必要な場合は ctx.storage.transactionSync() を使います(sql.exec に BEGIN TRANSACTION を直接書くことはできません)。
Alarms — 自分を未来に起こす
Alarms は「この時刻に自分を起こして alarm() ハンドラを実行して」と予約する仕組みです。cron トリガーが Worker あたり 3 本までの静的なスケジュールなのに対し、アラームはオブジェクトごとに 1 本をコードから動的に設定でき、バッチ処理・TTL・キューなどの部品になります。
export class Batcher extends DurableObject<Env> {
async enqueue(item: string) {
this.ctx.storage.kv.put(`item:${crypto.randomUUID()}`, item);
// アラーム未設定のときだけ 10 秒後に予約する
const current = await this.ctx.storage.getAlarm();
if (current === null) {
await this.ctx.storage.setAlarm(Date.now() + 10_000);
}
}
async alarm() {
// 溜まった item をまとめて処理する(kv.list で列挙 → 処理 → 削除)
}
}
アラームは at-least-once 実行が保証され、ハンドラが例外で失敗すると 2 秒から始まる指数バックオフで最大 6 回まで自動リトライされます。1 オブジェクトに設定できるアラームは同時に 1 つで、setAlarm() を重ねて呼ぶと上書きです。複数のイベントを扱いたい場合は、予定表をストレージに保存して alarm() の最後に次のアラームを張り直すパターンを使います。アラームハンドラのウォールタイム上限は 15 分です(2026-08 時点)。
WebSocket と Hibernation API
WebSocket は長寿命の双方向接続です。「接続をどこで受けるか」が問題になりますが、DO は単一インスタンスに複数クライアントの接続を集約できるため、チャットルームやマルチプレイヤーゲームの「部屋」の実装に最適です。
DO で WebSocket サーバーを作る API は 2 系統あります。
- Hibernation WebSocket API(推奨) —
this.ctx.acceptWebSocket(server)で接続を受け、メッセージはクラスのハンドラメソッドwebSocketMessage()/webSocketClose()/webSocketError()に配送されます。アイドル時に接続を維持したまま DO 本体を休止でき、休止中は Duration(GB-秒)課金が発生しません。 - 標準 WebSocket API —
server.accept()+addEventListener()。ブラウザと同じ書き味ですが、接続が開いている間ずっと DO がメモリに常駐し、課金が続きます。
serializeAttachment() に預けておきます。Hibernation API では、休止からの復帰時に constructor() が再実行され、インメモリ状態は消えています。接続ごとの状態は ws.serializeAttachment(value)(最大 16,384 バイト)で接続自体に添付しておき、ws.deserializeAttachment() で復元します。また ctx.setWebSocketAutoResponse() を使うと、ping/pong のような定型応答をDO を起こさずに返せます。1 つの DO が受けられる Hibernation WebSocket は最大 32,768 接続です(実際には CPU・メモリが先に上限になります)。
名前ごとに独立したカウンタを持つ DO を作り、RPC メソッドと SQLite ストレージを体験します。所要 20 分程度です。
Durable Objects(SQLite バックエンド)は Workers Free プランで利用できます。この章のハンズオンに有料プランは不要です(2026-08 時点)。
- DO 用テンプレートでプロジェクトを作ります。プロンプトでは Hello World example → Worker + Durable Objects → TypeScript を選択し、デプロイは「No」にします。
npm create cloudflare@latest -- do-counter cd do-counter src/index.tsの DO クラスを次のCounterに置き換えます。コンストラクタでテーブルを用意し、public メソッドを RPC として公開します。import { DurableObject } from "cloudflare:workers"; export class Counter extends DurableObject<Env> { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); // このインスタンス専用の SQLite にテーブルを用意する this.ctx.storage.sql.exec( "CREATE TABLE IF NOT EXISTS counter (name TEXT PRIMARY KEY, value INTEGER NOT NULL)", ); } async increment(amount = 1): Promise<number> { const rows = this.ctx.storage.sql .exec("SELECT value FROM counter WHERE name = 'total'") .toArray(); const next = (rows.length ? Number(rows[0].value) : 0) + amount; // sql.exec は同期なので、この読み取り→書き込みに割り込みは入らない this.ctx.storage.sql.exec( "INSERT OR REPLACE INTO counter (name, value) VALUES ('total', ?)", next, ); return next; } async getValue(): Promise<number> { const rows = this.ctx.storage.sql .exec("SELECT value FROM counter WHERE name = 'total'") .toArray(); return rows.length ? Number(rows[0].value) : 0; } }- 同じファイルの Worker 側ハンドラを置き換えます。クエリパラメータ
nameごとに別インスタンスへルーティングします。export default { async fetch(request, env): Promise<Response> { const url = new URL(request.url); // name ごとに世界で 1 つのインスタンスが対応する const name = url.searchParams.get("name") ?? "global"; const stub = env.COUNTERS.getByName(name); let value: number; if (url.pathname === "/increment") { value = await stub.increment(); } else { value = await stub.getValue(); } return new Response(`counter '${name}' = ${value}`); }, } satisfies ExportedHandler<Env>; wrangler.jsoncのバインディング名とクラス名を合わせ、exportsでクラスを宣言します(テンプレートが生成した宣言を書き換えます)。Env型はテンプレートがworker-configuration.d.tsに自動生成しています。{ "name": "do-counter", "main": "src/index.ts", "compatibility_date": "2026-08-01", "durable_objects": { "bindings": [ { "name": "COUNTERS", "class_name": "Counter" } ] }, "exports": { "Counter": { "type": "durable-object", "storage": "sqlite" } } }npx wrangler devで起動し、別ターミナルから curl で叩きます。
$ curl "http://localhost:8787/increment"
counter 'global' = 1
$ curl "http://localhost:8787/increment"
counter 'global' = 2
$ curl "http://localhost:8787/"
counter 'global' = 2
$ curl "http://localhost:8787/increment?name=team-a"
counter 'team-a' = 1
global と team-a が独立して数えられていれば成功です。npx wrangler deploy で本番へデプロイすると、世界のどこからアクセスしても同じ名前は同じインスタンスに集約されます。プロセスを再起動しても値が残ること(ストレージの永続性)も確認してみてください。
8-1 のプロジェクトに Hibernation API を使ったチャットルーム DO を追加し、部屋単位のブロードキャストを体験します。所要 20 分程度です。
src/index.tsにChatRoomクラスを追記します。WebSocket の接続受け入れは HTTP Upgrade の Request/Response を扱うため、RPC ではなくfetch()ハンドラを使います。export class ChatRoom extends DurableObject<Env> { async fetch(request: Request): Promise<Response> { // WebSocket ペアを作り、server 側をこの DO にアタッチする const pair = new WebSocketPair(); const [client, server] = Object.values(pair); // 標準 API の server.accept() ではなく Hibernation API を使う this.ctx.acceptWebSocket(server); return new Response(null, { status: 101, webSocket: client }); } async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { // この部屋に接続中の全クライアントへブロードキャスト for (const peer of this.ctx.getWebSockets()) { peer.send(`[${this.ctx.id.name}] ${message}`); } } async webSocketClose(ws: WebSocket, code: number, reason: string) { // compatibility date 2026-04-07 以降は Close 応答が自動化されており、 // この呼び出しは省略可能(呼んでも安全) ws.close(code, reason); } }- Worker の
fetchハンドラの先頭に、部屋へのルーティングを追加します(カウンタのルーティングはその後ろに残します)。const match = url.pathname.match(/^\/rooms\/([\w-]+)\/websocket$/); if (match) { if (request.headers.get("Upgrade") !== "websocket") { return new Response("Expected Upgrade: websocket", { status: 426 }); } // 部屋名ごとに 1 つの ChatRoom インスタンスに集約される const stub = env.CHAT_ROOMS.getByName(match[1]); return stub.fetch(request); } wrangler.jsoncにバインディングとexportsエントリを追加します。{ "durable_objects": { "bindings": [ { "name": "COUNTERS", "class_name": "Counter" }, { "name": "CHAT_ROOMS", "class_name": "ChatRoom" } ] }, "exports": { "Counter": { "type": "durable-object", "storage": "sqlite" }, "ChatRoom": { "type": "durable-object", "storage": "sqlite" } } }npx wrangler devを起動し、ブラウザのタブを 2 つ開いて、それぞれの開発者ツールのコンソールで接続します。const ws = new WebSocket("ws://localhost:8787/rooms/lobby/websocket"); ws.onmessage = (e) => console.log(e.data); ws.send("こんにちは");
片方のタブで ws.send("こんにちは") を実行すると、両方のタブのコンソールに [lobby] こんにちは が届けば成功です。/rooms/other/websocket に接続したタブにはメッセージが届かないこと(部屋の分離=インスタンスの分離)も確認しましょう。本格化する際は、送信者名を serializeAttachment() に載せて休止をまたいで復元する、入退室を通知する、履歴を sql.exec で保存するなどの拡張が定石です。
つまずきポイント
DO を URL で直接呼ぼうとする — Durable Objects はインターネットから直接リクエストを受けられません。必ず Worker がバインディング経由でスタブを作って呼び出します。「DO のエンドポイント URL」は存在せず、ルーティング・認証・検証は手前の Worker の仕事です。不正リクエストの遮断を Worker 側で行うと、DO への課金リクエストも減らせます。
クラスを export しただけでデプロイした / exports と migrations を併用した — コードにクラスがあるだけでは、ネームスペースはプロビジョニングされません。wrangler.jsonc の exports(または旧 migrations)での宣言が必要です。また exports と migrations は排他で、両方書くと検証エラーになります。一度 exports でデプロイすると旧方式へは戻れない点にも注意してください。デプロイ時の Durable Object exports reconciliation 出力にエラーの原因と修正候補が表示されます。
休止でインメモリ状態が消えて Map が空になる — Hibernation API では、アイドル時に DO がメモリから退避され、次のイベントで constructor() から再構築されます。接続一覧を Map に入れているだけだと休止後に空になります。接続ごとの状態は serializeAttachment() に載せ、コンストラクタで ctx.getWebSockets() から復元してください。逆に server.accept()(標準 API)や setTimeout / setInterval、送信側 WebSocket があると休止できず、Duration 課金が続きます。
アラームが 6 回失敗すると止まる — alarm() のリトライは指数バックオフで最大 6 回までです。依存先の長時間障害でリトライを使い切ると、次に setAlarm() を呼ぶまでアラームは再実行されません。恒久的に再試行したい処理は、ハンドラ内で例外を catch して自前で次のアラームを張り直します。また休止復帰時は alarm() の前に constructor() が走るため、コンストラクタで setAlarm() する場合は getAlarm() で既存の予約を確認してからにします。
理解度チェック
Q1. 世界中の複数リージョンから getByName("room-1") でアクセスすると、何が起きますか?
すべてのリクエストが、世界でただ 1 つの「room-1」インスタンスにルーティングされます。名前(から作られる ID)ごとにインスタンスはグローバルに一意で、これが複数クライアント間の協調ポイントになります。初回アクセス時のみ一意性確認のため数百 ms 程度の遅延が乗ることがあります。
Q2. ctx.storage.sql.exec() が使える条件と、使えない場合の代替は?
SQL API は SQLite ストレージバックエンドのクラス(exports で "storage": "sqlite"、旧方式なら new_sqlite_classes で作成)でのみ使えます。旧来の KV バックエンドのクラスで呼ぶとエラーになり、非同期 KV API(ctx.storage.get() / put() など)だけが使えます。新規ネームスペースは SQLite バックエンドのみ作成できます。
Q3. WebSocket Hibernation API が標準 WebSocket API より推奨される主な理由は?
クライアントの接続を維持したまま、アイドル時に DO 本体をメモリから休止できるからです。休止中は Duration(GB-秒)課金が発生しません。標準 API(server.accept())では接続中ずっと DO が常駐し、課金が続きます。休止するとインメモリ状態は失われるため、接続ごとの状態は serializeAttachment() で保全します。
Q4. alarm() ハンドラの実行保証とリトライ仕様は?
at-least-once(少なくとも 1 回)の実行が保証されます。ハンドラが未捕捉の例外で失敗すると、2 秒から始まる指数バックオフで最大 6 回まで自動リトライされます。設定できるアラームは 1 オブジェクトにつき同時に 1 つで、setAlarm() の重ね掛けは上書きになります。
Q5. カウンタの「読み取り→加算→書き込み」を素朴に書いても壊れないのはなぜですか?
各インスタンスはシングルスレッドで、ストレージ操作中は入力ゲートが他のイベントの割り込みを防ぐためです。さらに sql.exec() は同期 API なのでイベントループを譲る隙がありません。同一インスタンスへのリクエストは実質直列に処理されるため、アプリ側でロックを実装する必要がありません。
まとめと次章へ
- Durable Objects は「ID ごとに世界で 1 つ」のステートフルなインスタンス。シングルスレッド直列実行と強整合な専用 SQLite ストレージが、分散協調の難所を肩代わりする
- クラスは
DurableObjectを継承して export し、durable_objects.bindingsとexports(旧migrations)で登録する。呼び出しは Worker からgetByName()→ RPC /fetch() - ストレージは
sql.exec(同期・カーソル)と KV API。Alarms で自分を未来に起こし、バッチや TTL を実装できる - WebSocket は Hibernation API(
acceptWebSocket+ ハンドラメソッド)で受けると、接続維持と課金削減を両立できる - インメモリ状態は休止・再デプロイで消える。残すものはストレージか
serializeAttachmentへ
参考リンク:
- What are Durable Objects? / Lifecycle of a Durable Object
- Getting started / Build a counter
- SQLite-backed Durable Object Storage / Alarms
- Use WebSockets / WebSocket Hibernation server の例
- Durable Object class exports / Limits / Pricing
次章は Queues です。DO が「今この瞬間の協調」を担うのに対し、Queues は「あとで確実にやる」を担います。受け付けたら即応答し、重い処理は非同期のキューに逃がす — producer と consumer を両方書いて、Cloudflare 流の非同期メッセージングを体験します。