Cloudflare Durable Objects でチャットサーバーを作る
LINEみたいなやつ
2026.08.19

Cloudflare Workers は世界中のCDNエッジで動きます
速い、安い、スケールする
ただし、リクエストごとにどのインスタンスで実行されるかは分かりません
グローバル変数に値を置くこと自体はできますが、次のリクエストが同じインスタンスにくる保証はありません
インスタンス自体もいつ破棄されるか分かりません
つまり 状態を当てにできません
では「同じ部屋にいる全員に発言を配る」チャットのような本質的に状態を持つ処理をCDNエッジで実装したい場合どうすればいいの?!
そんなわがままに応えてくれるのが Durable Objects です
お盆暇だったので、Workers + Hono + Durable Objects でルーム型のチャットサーバーを実装してみて、Durable Objects の主要な機能を一通り触ってみました
なおコードはすべて実際に手元で動かして確認したものです
全体像の参考イメージ
1. Workers はステートレス
ほなら「状態」はどこに置く?
チャットサーバーを素朴に考えると、こんなかんじです
- 部屋ごとに、接続中の WebSocket の一覧を持つ
- 誰かが発言したら、その一覧全員に配る
- 発言を履歴として保存する
普通の Node.js サーバーなら、プロセスのメモリに Map<roomId, Set<WebSocket>> を持てば終わりです
ところが Workers では、リクエストは毎回別のインスタンスに届く可能性があります
たとえば、東京の Worker が持っている接続一覧を大阪の Worker は知りません
外部ストレージ(KV や D1)に一覧を置く手もありますが、WebSocket の接続そのものは「そのプロセスが握っているもの」なので、DB に保存して別プロセスから使うことはできません
接続を握っている場所と、配信を判断する場所が同じでなければならないというわけです
Durable Objects はこの要求にそのまま応えます
「名前を決めれば、世界に 1 つだけのインスタンスに必ず到達できる」しくみで、そのインスタンスは世界中のどのリージョンへの接続であっても状態を保持し続けます
2. 「名前 → 唯一のインスタンス」というモデル
Durable Objects の使い方はとても単純です
function roomStub(env: Env, room: string) { return env.CHAT_ROOM.get(env.CHAT_ROOM.idFromName(room)); }
env.CHAT_ROOM は wrangler.jsonc で宣言したバインディングで、idFromName() も get() もそれが持つメソッドです
idFromName() の戻り値は名前から導かれた決定的な ID です
東京の Worker が idFromName("lobby") を呼んでも、大阪の Worker が呼んでも、返る ID は同じです
その ID の get() で得られる stub は、世界に 1 つしかない lobby インスタンスを指します
なお get(idFromName(name)) をまとめた getByName(name) も用意されています
「名前 → ID → インスタンス」というモデルを見せたいので、この記事では 2 段階に分けています
そして 1 つのインスタンスが、同時に 2 つのリクエストを処理することはありません
「シングルスレッドなら Node.js も同じでは」と思うかもしれません
それはそうで、価値があるのはシングルスレッドであること自体ではありません
1 つ目の違いは、そのシングルスレッドが世界に 1 つしかないことです
Node.js のプロセスは負荷が増えれば増やすことになり、そうなると状態はプロセスの外(DB や Redis)へ逃がすしかありません
Durable Objects はルーム名で 1 つに固定されるので、状態をインスタンスの中に置いたままにできます
2 つ目の違いは input gate です
Node.js のシングルスレッドは await のたびに他のリクエストへ処理を明け渡します
「読んで、加工して、書き戻す」の途中で別のリクエストが割り込めば、普通に競合します
Durable Objects はストレージ操作の実行中、そのオブジェクトへ他のイベントを配送しません
await を挟んでも間に割り込まれないので、read-modify-write をそのまま書けます
ただし万能ではありません
1 つのイベントの中で await せずに複数の操作を投げた場合、それは意図した並行実行として扱われます
ストレージ以外の待ち(外部 API の呼び出しなど)をまたいで直列にしたいときは、6 章で触れる blockConcurrencyWhile() を使います
ルーム名とインスタンスの対応は次のイメージです
これだけで「部屋」という概念がインフラ側に実装されます
なお ID の導出方法は 3 種類用意されています
名前から導く idFromName()
文字列化した ID を復元する idFromString()
一意な ID を新規発行する newUniqueId()
チャットの部屋のように「人間が知っている名前」がある場合は idFromName() をつかうといったかんじです
3. とりあえず最小のチャット機能をつくる
まずは設定をおこないます
Durable Objects は wrangler.jsonc にバインディングと migration を書きます
{ "durable_objects": { "bindings": [{ "name": "CHAT_ROOM", "class_name": "ChatRoom" }] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["ChatRoom"] } ] }
migrations は Durable Objects クラスの追加・改名・削除を記録するものです
新規に作るなら new_sqlite_classes を選びます(new_classes は旧来の KV バックエンドで、
後から SQLite へ移行することはできないので、ここは最初に決め切る必要があります)
ここは料金プランの分岐点でもあります
無料プランで使える Durable Objects は SQLite バックエンドのものだけです
Worker 側は基本的にルーティングするだけです
app.get("/api/rooms/:room/ws", async (c) => { if (c.req.header("Upgrade")?.toLowerCase() !== "websocket") { return c.text("Expected an Upgrade: websocket request", 426); } const room = c.req.param("room"); const url = new URL(c.req.url); url.searchParams.set("room", room); return roomStub(c.env, room).fetch(new Request(url, c.req.raw)); });
Worker は「どの Durable Objects に渡すか」を決めたら、あとは関与しません
以降のやり取りはクライアントと Durable Objects の直通になります
受け取る Durable Objects 側はこうです
async fetch(request: Request): Promise<Response> { const { 0: client, 1: server } = new WebSocketPair(); this.ctx.acceptWebSocket(server, [user]); return new Response(null, { status: 101, webSocket: client }); }
WebSocketPair で 2 つの端点を作り、片方(server)を Durable Objects が握り、もう片方(client)を 101 レスポンスとして返す
これが Workers での WebSocket の作法です
ブロードキャストは、握っているソケットを列挙するだけです
private broadcast(message: ServerMessage, exclude?: WebSocket): number { const payload = JSON.stringify(message); let delivered = 0; for (const ws of this.ctx.getWebSockets()) { if (ws === exclude) continue; try { ws.send(payload); delivered++; } catch { // 閉じかけのソケットは無視する } } return delivered; }
接続一覧を自前の変数で管理していないところがポイントです
ctx.getWebSockets() はランタイムが持っている一覧を返します
4. Hibernation でコストを抑える
server.accept() で接続を受けると、その接続が生きている間ずっと Durable Objects をメモリに常駐させることになります
100 人が繋ぎっぱなしのチャットは、誰も喋っていなくても稼働時間を消費し続けます
有料プランならそのぶん課金され、無料プランなら日次の枠を削っていきます
代わりに WebSocket Hibernation API を使います
this.ctx.acceptWebSocket(server, [user]);
ctx.acceptWebSocket() で受けると、アイドル中はインスタンスがメモリから退避されます
接続は切れません
メッセージが届いた時点でインスタンスが復帰し、webSocketMessage() ハンドラが呼ばれます
だから接続一覧をインスタンス変数ではなくランタイム(ctx.getWebSockets())に持たせるわけです
第 2 引数はタグです
ここではユーザー名を付けているので、後から絞り込めます
for (const ws of this.ctx.getWebSockets(user.trim())) { if (send(ws, payload)) delivered++; }
ただし Hibernation には代償があります
インスタンス変数(メモリ)は失われます
復帰後も残したい小さな状態は、ソケットに紐づける形で保存しておくとよいです
setAttachment(server, { user, joinedAt: Date.now(), windowStartedAt: 0, sentInWindow: 0, });
このサンプルでは「誰の接続か」と「簡易レート制限のカウンタ」を載せています
レート制限のカウンタのような、DB に書くほどではないがメモリでは消えては困る値の置き場として便利です
もう一つ、忘れがちな落とし穴があります
キープアライブの ping です
多くの WebSocket クライアントは接続維持のために定期的に ping を送ります
これが毎回 webSocketMessage() を叩けば、そのたびに Durable Objects が起きることになり、Hibernation の意味が薄れます
そこで代理応答を設定します
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
この 1 行で、クライアントが "ping" を送ってきたときランタイムが "pong" を返すようになります
Durable Objects は起きません
5. 切れたら繋ぎ直す
Hibernation は接続を維持したままインスタンスを退避してくれます
しかし、接続が絶対に切れないわけではありません
デプロイ時、ネットワーク遮断時、モバイル回線の切り替え時、タブのスリープ復帰時などのタイミングで、WebSocket は普通に落ちます
つまり再接続はクライアント側の必須実装です
本記事のサンプルを作るときも、最初は素朴にこう書いていました
socket.addEventListener("close", () => { setTimeout(connect, 3000); }); socket.addEventListener("error", () => appendNotice("WebSocket でエラーが発生しました"));
動作確認のあと開発サーバーを落としたら、画面にエラーが延々と流れ続けました
3 秒ごとに無限リトライし、しかも error と close の両方が発火するので 1 回の失敗が 2 行になっていたわけです
直したものが次です
socket.addEventListener("close", () => { setConnected(false); clearInterval(keepAlive); socket = null; if (manualClose) { $("state").textContent = "切断しました"; return; } if (retry >= MAX_RETRIES) { $("state").textContent = "接続できませんでした。「接続」で再試行してください"; appendNotice(`再接続を ${MAX_RETRIES} 回試みましたが失敗しました`); return; } // 失敗のたびに間隔を倍にする(1s, 2s, 4s, 8s, 16s) const delay = 1000 * 2 ** retry; retry++; $("state").textContent = `切断されました(${delay / 1000} 秒後に再接続 ${retry}/${MAX_RETRIES})`; reconnectTimer = setTimeout(connect, delay); }); // error の直後には必ず close が来るので、通知は close 側に集約する socket.addEventListener("error", () => console.warn("WebSocket error"));
入れているのは 4 点です
指数バックオフ
固定間隔でリトライすると、サーバー復旧の瞬間に全クライアントのリトライが揃って殺到してしまう
打ち切り
上限を超えたら自動リトライをやめ、ユーザーの操作に委ねる(無限リトライは落ちているサーバーを叩き続けるだけ)
通知は close に集約
error の直後には必ず close がくる(両方でログを出すと二重になる)
多重接続の防止
再接続タイマーのハンドルを保持し、手動の「接続」「切断」時に clearTimeout する
もう一つの問題点、「再接続後に取りこぼした発言履歴をどう埋めるか」です
このサンプルは接続時の welcome メッセージで履歴をまとめて返しています
サーバー側のデータが常に真実の source なので、クライアント側に差分管理を持たせずに済みます
発言量が多いなら「最後に受け取った id 以降をください」と要求する形に寄せるのが自然です
6. 履歴を残す
Durable Objects は自分専用の SQLite を持っています
外部 DB ではなく、そのインスタンスと同じ場所にあるストレージです
this.sql.exec(` CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL, text TEXT NOT NULL, sent_at INTEGER NOT NULL, is_system INTEGER NOT NULL DEFAULT 0 ) `);
書き込みも読み出しも、同じインスタンスからは 同期的に 扱えます
const [row] = this.sql .exec<{ id: number }>( "INSERT INTO messages (author, text, sent_at, is_system) VALUES (?, ?, ?, ?) RETURNING id", author, text, sentAt, system ? 1 : 0, ) .toArray();
ネットワーク越しの DB ではなく、そのインスタンスに属するストレージなので、await は要りません
この「近さ」が Durable Objects の強みで、1 回の発言ごとに DB へラウンドトリップする設計と比べて、レイテンシの考え方がまるで変わります
一覧や検索が要るデータは SQL、単発の小さな値は KV 形式の API が手軽です
await this.ctx.storage.put("room", room);
SQLite バックエンドでは、この KV API も内部的には SQL のテーブルに落ちています
使い分けは「配列で扱いたいか、単発の値か」くらいの感覚で十分です
初期化で一つ注意点があります
テーブル作成はコンストラクタでやりたいところですが、素朴に書くと「テーブルがまだ無いのに INSERT が走る」可能性があります
そこで次のように囲みます
ctx.blockConcurrencyWhile(async () => { this.sql.exec("CREATE TABLE IF NOT EXISTS messages (...)"); });
blockConcurrencyWhile() は、中の処理が終わるまで他のリクエスト処理を止めてくれます
7. 自分で自分を起こす
チャットの古い発言は削除したいが、そのためだけに cron を回すのは大げさです
Durable Objects には Alarms があります
自分で自分を起こすタイマーです
private async ensureAlarm(): Promise<void> { if ((await this.ctx.storage.getAlarm()) === null) { await this.ctx.storage.setAlarm(Date.now() + ALARM_INTERVAL_MS); } }
発言があったときに、Alarm が未設定なら 1 時間後を予約します
時間が来ると alarm() が呼ばれます
async alarm(): Promise<void> { const retentionHours = Number(this.env.MESSAGE_RETENTION_HOURS ?? "24") || 24; const cutoff = Date.now() - retentionHours * 60 * 60 * 1000; const pruned = this.sql.exec("DELETE FROM messages WHERE sent_at < ?", cutoff).rowsWritten; const [row] = this.sql.exec<{ count: number }>("SELECT COUNT(*) AS count FROM messages").toArray(); const remaining = row?.count ?? 0; console.log(`[alarm] pruned=${pruned} remaining=${remaining}`); if (remaining > 0) { await this.ctx.storage.setAlarm(Date.now() + ALARM_INTERVAL_MS); } }
メッセージが残っていれば次の掃除を予約し、空になったら予約しません
つまり 使われていない部屋は、何もしないまま眠り続けます
全ルームを定期スキャンする cron と違って、掃除のコストが「実際に使われている部屋の数」に比例します
Alarm は失敗すると自動でリトライされ、setAlarm() は同じインスタンスに対して 1 つだけ有効です(次を入れると上書き)
ちなみに私は目覚まし時計をかけても起きれません
8. Worker から Durable Objects を呼んでみる
stub.fetch(request) で、Request/Response をやり取りする
WebSocket の更新時のように Request そのものを渡したい場合はこちらが必要です
RPC をつかう
DurableObject を継承したクラスの public メソッドは、Worker から直接呼べます
async history(limit: number = DEFAULT_HISTORY_LIMIT): Promise<ChatMessage[]> { return this.readHistory(limit); }
Worker 側はこんなかんじ
app.get("/api/rooms/:room/history", async (c) => { const limit = Number(c.req.query("limit") ?? "50"); const messages = await roomStub(c.env, c.req.param("room")).history(limit); return c.json({ room: c.req.param("room"), messages }); });
URL を組み立てて、JSON にして、パースして……という往復がまるごと消えます
しかも型が自動的に付きます
wrangler types が生成する Env は Durable Objects のバインディングを次のように型付けしてくれるからです
CHAT_ROOM: DurableObjectNamespace<import("./src/index").ChatRoom>;
この型引数のおかげで、stub.history(50) の引数も戻り値も補完が効き、間違えればコンパイルエラーになります
Durable Objects を「別サービス」ではなく「ちょっと遠くにあるオブジェクト」として扱える、という感覚です
使い分けはシンプルで、WebSocket の更新だけ fetch、それ以外は RPC でOK
なお wrangler types は wrangler.jsonc から Env を生成するので、手書きの Env は不要です
ただしシークレットは設定ファイルに書かないため生成対象になりません
宣言のマージで足します
declare global { interface Env { ADMIN_TOKEN?: string; } }
9. テストする
Durable Objects のテストは「モックを書くのが大変そう」と身構えるところですが、実際にはモックしません
@cloudflare/vitest-pool-workers を使うと、テストが 本物の workerd 上 で動きます
export default defineConfig({ plugins: [ cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" }, miniflare: { bindings: { ADMIN_TOKEN: "test-token" }, }, }), ], });
WebSocket も普通に張れます
const response = await SELF.fetch( `${ORIGIN}/api/rooms/${room}/ws?user=${encodeURIComponent(user)}`, { headers: { Upgrade: "websocket" } }, ); expect(response.status).toBe(101); const ws = response.webSocket;
Durable Objects らしい振る舞いをそのまま検証できます
it("同じルームの参加者全員に発言がブロードキャストされる", async () => { const alice = await connect("broadcast-room", "alice"); await alice.waitFor("welcome"); const bob = await connect("broadcast-room", "bob"); const welcome = await bob.waitFor("welcome"); expect(welcome.members).toBe(2); alice.ws.send(JSON.stringify({ type: "chat", text: "こんにちは" })); const forAlice = await alice.waitFor("message"); const forBob = await bob.waitFor("message"); expect(forAlice.message).toMatchObject({ user: "alice", text: "こんにちは" }); expect(forBob.message).toMatchObject({ user: "alice", text: "こんにちは" }); });
「切断後も履歴 API から読める」「ルームが違えば履歴が混ざらない」といった、本番で失敗できない項目をテストできます
10. デプロイと運用の注意
デプロイ自体は Workers と変わりません
機密性の高い定数の登録
npx wrangler secret put ADMIN_TOKEN
デプロイ
npx wrangler deploy
運用で気に留めておくべき点をいくつか挙げます
migrations は必ず書く
Durable Objects クラスの追加・改名・削除は wrangler.jsonc の migrations に記録します
ここを忘れるとデプロイが通りません
逆に言えば、設定ファイルがそのままスキーマ変更の履歴になります
無料プランでも使える
Durable Objects は有料プラン専用ではありません
ただし条件があり、SQLite バックエンド(new_sqlite_classes)のものだけが無料プランの対象です
KV バックエンド(new_classes)は有料プラン専用のままです
このサンプルは最初から new_sqlite_classes なので、無料プランのまま本番にデプロイできます
無料プランの上限は日次で次のとおりです(2026 年 8 月時点)
- リクエスト 100,000 / 日
- Compute Duration 13,000 GB-s / 日
- SQLite の行読み取り 500 万 / 日、行書き込み 100,000 / 日
- SQLite ストレージ 5 GB(総量)
上限に達すると、その種類の操作がエラーになります
課金の考え方
有料プラン(月 $5 の最低利用料)では、リクエスト 100 万 / 月と Compute Duration 400,000 GB-s / 月が含まれ、超過分が従量課金になります
つまり課金の軸はリクエスト数と、インスタンスがアクティブだった時間です
「繋ぎっぱなしだが誰も喋っていない」時間を課金対象から外せるかどうかで、コストは大きく変わります
※ ここで Hibernation を使った実装が効いてくる
無料プランでも同じで、日次の Compute Duration を食い潰さずに済みます
ロケーション
Durable Objects は最初にアクセスされた場所の近くに作られます
ルーム参加者が特定地域に偏るような仕様なら locationHint を検討する余地があります
つまずいたところ
最後に、実装中に実際に踏んだものを共有します
new_sqlite_classesとnew_classes: 新規なら前者(既存の KV バックエンドのクラスを後から SQLite には変えられません)package.jsonの"type": "module": 無いとvitest.config.tsの読み込みが「ESM only なのに require された」で落ちます@cloudflare/vitest-pool-workersの API 変更: v0.22 系(vitest 4 対応)ではdefineWorkersConfigではなく Vite プラグインのcloudflareTest()を使います(cloudflare:testの型は"@cloudflare/vitest-pool-workers/types"を tsconfig のtypesに入れる)- npm 11 の install スクリプト承認:
workerd/esbuildの postinstall がブロックされると、wrangler もテストも動きません(npm install-scripts approve workerd esbuildが要る) webSocketClose()のcode: 1006(異常終了)はそのまま送り返せないので 1000 に丸めます- クライアントの無限リトライ: 5 章の通りです(サーバーを止めた瞬間に気づきました)
まとめ
Durable Objects は「Workers に状態を足す機能」と説明されがちですが、実際に書いてみると印象はもう少し具体的です
- 名前を決めれば、世界に 1 つの場所に必ず届く(
idFromName) - その場所は接続を握り続けられる(WebSocket Hibernation)
- その場所は自分のデータベースを持っている(内蔵 SQLite)
- その場所は自分で自分を起こせる(Alarms)
- その場所は普通のオブジェクトのように呼べる(RPC)
チャット、共同編集、ゲームのルーム、レートリミッタ、ジョブのコーディネーター
「調整役が 1 つ必要」な場面すべてが対象になります
サーバーを立てずにここまでできるのは、素直に面白い機能だと思います
検証環境
- wrangler 4.124.0
- workerd 1.20260815.1
- hono 4.13.3
- vitest 4.1.11
- @cloudflare/vitest-pool-workers 0.22.0
- compatibility_date 2026-08-01