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_ROOMwrangler.jsoncidFromName()get()idFromName()東京の Worker が
idFromName("lobby")その ID の
get()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ただし万能ではありません
1 つのイベントの中で
awaitストレージ以外の待ち(外部 API の呼び出しなど)をまたいで直列にしたいときは、
blockConcurrencyWhile()ルーム名とインスタンスの対応は次のイメージです
これだけで「部屋」という概念がインフラ側に実装されます
なお ID の導出方法は 3 種類用意されています
名前から導く
idFromName()文字列化した ID を復元する
idFromString()一意な ID を新規発行する
newUniqueId()チャットの部屋のように「人間が知っている名前」がある場合は
idFromName()3. とりあえず最小のチャット機能をつくる
まずは設定をおこないます
Durable Objects は
wrangler.jsonc{ "durable_objects": { "bindings": [{ "name": "CHAT_ROOM", "class_name": "ChatRoom" }] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["ChatRoom"] } ] }
migrations新規に作るなら
new_sqlite_classesnew_classes後から 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 }); }
WebSocketPairserverclient
これが 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()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()そこで代理応答を設定します
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 でエラーが発生しました"));
動作確認のあと開発サーバーを落としたら、画面にエラーが延々と流れ続けました
修正します
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
errorclose多重接続の防止
再接続タイマーのハンドルを保持し、手動の「接続」「切断」時に
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. 自分で自分を起こす
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()ちなみに私は目覚まし時計をかけても起きれません
8. Worker から Durable Objects を呼んでみる
stub.fetch(request) で、Request/Response をやり取りする
WebSocket の更新時のように Request そのものを渡したい場合はこちらが必要です
RPC をつかう
DurableObjectasync 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 typesEnvCHAT_ROOM: DurableObjectNamespace<import("./src/index").ChatRoom>;
この型引数のおかげで、
stub.history(50)なお
wrangler typeswrangler.jsoncEnvEnvただしシークレットは設定ファイルに書かないため生成対象になりません
宣言のマージで足します
declare global { interface Env { ADMIN_TOKEN?: string; } }
使い分けはシンプルで、WebSocket の更新だけ fetch
9. テストする
Durable Objects のテストは「モックを書くのが大変そう」と身構えるところですが、実際にはモックしません
@cloudflare/vitest-pool-workers※ workerd = Cloudflare Workers をローカル環境で動かすための実行環境
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 を使った実装が効いてくる
無料プランでも同じで、日次の無料枠を食い潰さずに済みます
ロケーション
Durable Objects は最初にアクセスされた場所の近くに作られます
ルーム参加者が特定地域に偏るような仕様なら
locationHintまとめ
- 名前を決めれば、世界に 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