日誌のようなもの

Cloudflare Durable Objects でチャットサーバーを作る

LINEみたいなやつ

2026.08.19


01a01da0-7b89-70e3-8114-eef1dae391ca.webp

Cloudflare Workers は世界中のCDNエッジで動きます
速い、安い、スケールする
ただし、リクエストごとにどのインスタンスで実行されるかは分かりません
グローバル変数に値を置くこと自体はできますが、次のリクエストが同じインスタンスにくる保証はありません
インスタンス自体もいつ破棄されるか分かりません
つまり 状態を当てにできません

では「同じ部屋にいる全員に発言を配る」チャットのような本質的に状態を持つ処理をCDNエッジで実装したい場合どうすればいいの?!
そんなわがままに応えてくれるのが Durable Objects です

お盆暇だったので、Workers + Hono + Durable Objects でルーム型のチャットサーバーを実装してみて、Durable Objects の主要な機能を一通り触ってみました
なおコードはすべて実際に手元で動かして確認したものです

全体像の参考イメージ
ブラウザ・Worker・ChatRoom Durable Object の関係を示した全体構成図

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_ROOMwrangler.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() を使います

ルーム名とインスタンスの対応は次のイメージです

どのリージョンの Worker から idFromName を呼んでも同じインスタンスに到達することを示した図

これだけで「部屋」という概念がインフラ側に実装されます

なお 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 秒ごとに無限リトライし、しかも errorclose の両方が発火するので 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"));

切断後に 1、2、4、8、16 秒と待ち時間を倍にして再接続し 5 回で打ち切るタイムライン

入れているのは 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 を予約し、削除後に残件があれば再予約、空なら予約しないループ図

Alarm は失敗すると自動でリトライされ、setAlarm() は同じインスタンスに対して 1 つだけ有効です(次を入れると上書き)

ちなみに私は目覚まし時計をかけても起きれません

8. Worker から Durable Objects を呼んでみる

fetch で Request を渡す方式と RPC でメソッドを直接呼ぶ方式の比較図

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 typeswrangler.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.jsoncmigrations に記録します
ここを忘れるとデプロイが通りません
逆に言えば、設定ファイルがそのままスキーマ変更の履歴になります

無料プランでも使える
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_classesnew_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