$ cat ./blog/durable-objects-hibernation-chat.md
Cloudflare Durable Objects の Hibernation API 実践 — 長時間 WebSocket を安価に運用する
Durable Objects の WebSocket Hibernation API を使って、長時間接続でも Duration 課金が積み上がらない最小チャットサーバを組む方法。ctx.acceptWebSocket と 2 つのハンドラ、serializeAttachment、setWebSocketAutoResponse を、実際に動くコードでまとめます。
- author
- r43
- date
- (2026年4月25日)
- reading
- 4 min
- commit
- 210733b
個人開発で WebSocket を本格的に使いたい場面は意外と多いものです。チャット、リアルタイム通知、プレゼンス管理、共同編集。ただ、Cloudflare Workers + Durable Objects で素直に組むと、接続中は DO のインスタンスがメモリに保持され、長時間接続 × アイドル時間分の Duration 課金が膨らみます。これを解決するのが Durable Objects の WebSocket Hibernation API です。本記事では最小のチャットルームを例に、API の使い所とハマりポイントを実装込みでまとめます。
到達点と前提
作るもの: 1 ルーム = 1 DO インスタンス の最小チャット。クライアントの発言を同じルームの全員にブロードキャストし、アイドル中は DO を hibernate させることで Duration 課金を抑えます。
検証バージョン(2026-04 時点):
- Wrangler v4.84 /
@cloudflare/workers-types 2026-04 系
- Worker 側は Hono v4.12 を使用
なぜ Hibernation API なのか
通常の webSocket.accept() で受けた WebSocket は、Durable Object のインスタンスをメモリに保持し続けます。これが Cloudflare Pricing 上の duration 課金対象で、1 本の長時間接続でも課金が積み上がります。
Hibernation API (ctx.acceptWebSocket(ws)) で受け付けた WebSocket は、アイドル期間中に DO インスタンス自体を hibernate させられます。公式ドキュメント が明記するとおり:
Durable Objects that are idle and eligible for hibernation are not billed for duration, even before the runtime has hibernated them.
つまり、接続は維持したまま、メモリ保持コストだけをゼロにできる。一日中つなぎっぱなしの WebSocket 接続を持つアプリ(オンラインエディタ、常駐クライアント、ダッシュボード)では、アイドル時間が支配的になる分だけ Duration 課金が抑えられ、接続パターンによっては課金構造が大きく変わり得ます(Duration の単価は 2026-04 時点で $12.50 / M GB-s、筆者試算)。
API の全体像
Hibernation API は Durable Object クラスに次のように実装します。
ctx.acceptWebSocket(ws, tags?) — 受付け(通常の ws.accept() の代替)。tags は string[] で任意
- クラスメソッドとして定義するイベントハンドラ:
webSocketMessage(ws, message) — 受信時
webSocketClose(ws, code, reason, wasClean) — 切断時
ctx.getWebSockets(tag?) — hibernation 後も含めて現在受け付けている全 WebSocket を取得
ws.serializeAttachment(value) / ws.deserializeAttachment() — 2,048 byte までの per-ws 状態を hibernation を跨いで保持
ctx.setWebSocketAutoResponse(pair) — 特定メッセージ(例: ping)に対し、DO を起こさずに自動応答
addEventListener("message", ...) で書いていた資産はクラスメソッド形式へ書き換えが必要です。
実装
wrangler.jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "r43lab-do-hibernation-chat",
"main": "src/index.ts",
"compatibility_date": "2026-04-07",
"durable_objects": {
"bindings": [
{ "name": "ROOM", "class_name": "ChatRoom" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["ChatRoom"] }
],
"observability": { "enabled": true }
}
migrations は DO 初回デプロイで必須。new_sqlite_classes に DO クラス名を書かないと wrangler deploy が止まります。new_classes(レガシー Standard backend)と new_sqlite_classes(SQLite backend)があり、2026 年時点の新規プロジェクトは new_sqlite_classes を使うのが公式推奨です(Free プランは SQLite backend のみ対応)。
src/room.ts — Durable Object
import { DurableObject } from "cloudflare:workers";
type Env = { ROOM: DurableObjectNamespace<ChatRoom> };
export class ChatRoom extends DurableObject<Env> {
async fetch(req: Request): Promise<Response> {
if (req.headers.get("upgrade") !== "websocket") {
return new Response("expected websocket", { status: 426 });
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// Hibernation 対応で接続を受ける
this.ctx.acceptWebSocket(server, ["chat"]);
// per-ws のメタデータ (2,048 byte 以内) を attach
const userId = crypto.randomUUID().slice(0, 8);
server.serializeAttachment({ userId, joinedAt: Date.now() });
// ping は DO を起こさずに pong を返す
this.ctx.setWebSocketAutoResponse(
new WebSocketRequestResponsePair("ping", "pong")
);
return new Response(null, { status: 101, webSocket: client });
}
// クライアントからのメッセージを同じタグの全 ws にブロードキャスト
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
const meta = ws.deserializeAttachment() as
| { userId: string; joinedAt: number }
| null;
const text = typeof message === "string" ? message : "(binary)";
const payload = JSON.stringify({
from: meta?.userId ?? "anon",
text,
at: Date.now(),
});
for (const peer of this.ctx.getWebSockets("chat")) {
try {
peer.send(payload);
} catch {
// 送信失敗した peer は webSocketClose 側で回収されるので無視
}
}
}
async webSocketClose(ws: WebSocket, code: number, _reason: string, wasClean: boolean) {
ws.close(code, wasClean ? "bye" : "closed");
}
}
ポイント:
ctx.acceptWebSocket(server, ["chat"]) の 第 2 引数(tag) を使うと、getWebSockets("chat") で絞り込めます。1 DO 内に種類違いの ws を混ぜる設計で有効
serializeAttachment は JSON 化可能な値のみ、上限 2,048 byte。長いプロフィールは attach せず、ID だけ持たせて実体は ctx.storage に逃がす
setWebSocketAutoResponse は 1 DO インスタンスに対し 1 ペアのみ(ping/pong 用途)
src/index.ts — Worker エントリ
import { Hono } from "hono";
type Bindings = { ROOM: DurableObjectNamespace };
const app = new Hono<{ Bindings: Bindings }>();
app.get("/ws/:room", (c) => {
if (c.req.header("upgrade") !== "websocket") {
return c.text("expected websocket upgrade", 426);
}
const id = c.env.ROOM.idFromName(c.req.param("room"));
const stub = c.env.ROOM.get(id);
return stub.fetch(c.req.raw);
});
app.get("/", (c) => c.text("hibernation chat ok\n"));
export { ChatRoom } from "./room";
export default app;
idFromName(ルーム名) から DO ID を派生させることで、同じルーム名の接続が同じ DO インスタンスに集約されます。ルーム名が違えば別 DO に分かれ、自然に水平分散されます。
落とし穴
migrations を書き忘れる — 初回デプロイで DO クラス未登録エラー。2026 年現在の公式推奨は new_sqlite_classes(Free プランは SQLite backend のみ、Standard backend の new_classes はレガシー扱い)。新規プロジェクトでは new_sqlite_classes: ["ChatRoom"] を使う
- 全 ws が 1 DO に集まらない設計 —
idFromName(ルーム名) を使わないと broadcast が届かない。newUniqueId() にすると毎回別 DO になって会話が分裂する
ctx.acceptWebSocket を通常の ws.accept() と混在させる — 同じ DO 内で hibernatable と非 hibernatable を混在させると hibernation が動かない。どちらかに統一する
serializeAttachment は 2,048 bytes 制限、setWebSocketAutoResponse の request/response は各 2,048 characters 制限 — 超えると runtime エラー。長いメタは ctx.storage に逃がす
- auto-response は 1 ペア限定 — ping/pong 以外の keepalive が必要なら
alarm() と組み合わせる
webSocketError(ws, error) ハンドラも定義可能 — 公式 API リファレンスに webSocketError(ws: WebSocket, error: unknown): void | Promise<void> として明記。明示的に実装すると、hibernation 中の例外も拾いやすい
getWebSockets(tag?) の tag は単一 string — 複数タグの OR 絞り込みが必要なら getWebSockets を複数回呼んで結果をマージする
まとめ
Hibernation API は、長時間 WebSocket を扱う個人プロジェクトにとって コスト構造を根本から変える 機能です。ctx.acceptWebSocket() + 2 つのハンドラ + serializeAttachment を押さえれば、同時接続が増えてもアイドル時の課金はフラットに保てます。
次回は Astro v5 Content Collections で技術ブログを作る — まさにこの r43lab.com を作った経験の再解説を書く予定です。
参考
$ tree ./repo/
記事で登場したコードをひとつの最小リポジトリとしてまとめました。ファイルツリーから切り替えて、全体の構造をそのまま確認できます。
┌─durable-objects-hibernation-chat──────────files08──────────────────────●●●─┐$cat./src/index.tsts
# このプロジェクトは DO と WebSocket だけで完結するため、通常の dev では secret 不要。
# 後から認証・外部 API を足す際に、この形式で追加する想定でテンプレを残します。
#
# cp .dev.vars.example .dev.vars
#
# EXAMPLE_API_KEY=
# r43lab-do-hibernation-chat
Cloudflare Workers + Durable Objects の **WebSocket Hibernation API** を使った最小チャットルーム。
[記事本文](https://r43lab.com/blog/durable-objects-hibernation-chat) と対になるコピペ用リポジトリです。
## Quick start
```bash
bun install
# 初回デプロイ (durable_objects の migrations が走る)
npx wrangler deploy
# ローカル dev
npx wrangler dev
# 動作確認
# ブラウザで http://localhost:8787/public/client.html を開き、複数タブで接続する
# または CLI から websocat 等で ws://localhost:8787/ws/general に接続
```
## 仕組み
- `/ws/:room` に WebSocket upgrade リクエストが来ると、ルーム名から `idFromName` で Durable Object の ID を派生し、その DO にフォワード
- DO 側は `ctx.acceptWebSocket(server, ["chat"])` で **Hibernation 対応で接続を受理**
- メッセージ到着時は `webSocketMessage` ハンドラが呼ばれ、`ctx.getWebSockets("chat")` で全 ws にブロードキャスト
- アイドル中は **DO インスタンスが hibernate** され、Duration 課金が発生しない
- `ping` メッセージには `ctx.setWebSocketAutoResponse` により **DO を起こさずに `pong` を自動応答**
## 依存バージョンについて
`package.json` は **記事執筆時点 (2026-04) のスナップショット** です。Workers ランタイムの WebSocket API は継続的に拡張されているため、clone 後は一度最新化して動作確認することを推奨します。
```bash
bun outdated
bun update
npx wrangler dev
```
## Stack
Hono v4 · Cloudflare Durable Objects · WebSocket Hibernation API · Wrangler v4
{
"name": "r43lab-do-hibernation-chat",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"tail": "wrangler tail --format=pretty"
},
"dependencies": {
"hono": "^4.12.14"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260420.1",
"typescript": "^6.0.3",
"wrangler": "^4.84.0"
}
}
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>r43lab hibernation chat — demo client</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: #0c1117;
color: #e8ecf1;
padding: 1.25rem;
max-width: 48rem;
}
h1 { font-size: 1.25rem; margin: 0 0 1rem; }
.row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
input[type="text"] {
flex: 1;
min-width: 8rem;
padding: 0.5rem 0.7rem;
background: #141b24;
color: inherit;
border: 1px solid #2a3342;
border-radius: 2px;
font: inherit;
}
button {
padding: 0.5rem 0.9rem;
background: #e5a02a;
color: #0c1117;
border: 0;
border-radius: 2px;
font: inherit;
font-weight: 700;
cursor: pointer;
}
button.secondary { background: transparent; color: #e8ecf1; border: 1px solid #2a3342; font-weight: 400; }
#log {
background: #141b24;
border: 1px solid #2a3342;
border-radius: 2px;
padding: 0.75rem 1rem;
height: 22rem;
overflow-y: auto;
white-space: pre-wrap;
font-size: 0.9rem;
line-height: 1.5;
}
.meta { color: #9aa4b2; }
.ok { color: #80b58a; }
.err { color: #d47b8f; }
.from { color: #79b4d4; }
</style>
</head>
<body>
<h1>r43lab hibernation chat — demo client</h1>
<div class="row">
<input id="room" type="text" value="general" placeholder="ルーム名" />
<button id="connect">connect</button>
<button id="disconnect" class="secondary">disconnect</button>
</div>
<div class="row">
<input id="msg" type="text" placeholder="メッセージを入力して Enter" />
<button id="send">send</button>
</div>
<div id="log" role="log" aria-live="polite"></div>
<script>
const $ = (id) => document.getElementById(id);
const log = $("log");
const write = (html) => {
const div = document.createElement("div");
div.innerHTML = html;
log.appendChild(div);
log.scrollTop = log.scrollHeight;
};
let ws = null;
let pingTimer = null;
const esc = (s) =>
String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
$("connect").addEventListener("click", () => {
if (ws) { write('<span class="err">already connected</span>'); return; }
const room = $("room").value.trim() || "general";
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const url = `${proto}//${location.host}/ws/${encodeURIComponent(room)}`;
write(`<span class="meta">[${new Date().toLocaleTimeString()}] connecting to ${esc(url)}</span>`);
ws = new WebSocket(url);
ws.addEventListener("open", () => {
write('<span class="ok">[open]</span>');
// keepalive: DO を起こさない auto-response と合わせて ping を定期送信
pingTimer = setInterval(() => ws?.send("ping"), 20000);
});
ws.addEventListener("message", (ev) => {
if (ev.data === "pong") return;
try {
const { from, text, at } = JSON.parse(ev.data);
const t = new Date(at).toLocaleTimeString();
write(`<span class="meta">[${t}]</span> <span class="from">${esc(from)}:</span> ${esc(text)}`);
} catch {
write(`<span class="meta">[raw]</span> ${esc(ev.data)}`);
}
});
ws.addEventListener("close", (ev) => {
write(`<span class="err">[close] code=${ev.code}</span>`);
if (pingTimer) clearInterval(pingTimer);
ws = null;
});
ws.addEventListener("error", () => write('<span class="err">[error]</span>'));
});
$("disconnect").addEventListener("click", () => ws?.close(1000, "bye"));
const sendMsg = () => {
if (!ws || ws.readyState !== WebSocket.OPEN) {
write('<span class="err">not connected</span>');
return;
}
const v = $("msg").value.trim();
if (!v) return;
ws.send(v);
$("msg").value = "";
};
$("send").addEventListener("click", sendMsg);
$("msg").addEventListener("keydown", (e) => { if (e.key === "Enter") sendMsg(); });
</script>
</body>
</html>
import { Hono } from "hono";
type Bindings = {
ROOM: DurableObjectNamespace;
ASSETS: Fetcher;
};
const app = new Hono<{ Bindings: Bindings }>();
app.get("/", (c) => c.text("hibernation chat ok\n"));
app.get("/ws/:room", (c) => {
if (c.req.header("upgrade") !== "websocket") {
return c.text("expected websocket upgrade", 426);
}
// ルーム名から DO ID を派生させる → 同じルーム名は同じ DO インスタンスに集約
const id = c.env.ROOM.idFromName(c.req.param("room"));
const stub = c.env.ROOM.get(id);
return stub.fetch(c.req.raw);
});
// 静的アセット (public/) のフォールバック。
// WebSocket upgrade はここまで届かないように一応ガードしておく。
app.all("*", (c) => {
if (c.req.header("upgrade") === "websocket") {
return c.text("websocket upgrade not handled at this path", 426);
}
return c.env.ASSETS.fetch(c.req.raw);
});
export { ChatRoom } from "./room";
export default app;
import { DurableObject } from "cloudflare:workers";
type Env = {
ROOM: DurableObjectNamespace<ChatRoom>;
};
// ChatRoom — Hibernation 対応の最小チャットルーム Durable Object
//
// 1 ルーム = 1 DO インスタンス。`idFromName(ルーム名)` で ID を派生させる前提で、
// 同じルーム名のクライアントはすべてこの同じ DO に集約される。
export class ChatRoom extends DurableObject<Env> {
async fetch(req: Request): Promise<Response> {
if (req.headers.get("upgrade") !== "websocket") {
return new Response("expected websocket", { status: 426 });
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// Hibernation 対応で接続を受ける。tags を渡しておくと getWebSockets(tag) で絞り込める。
this.ctx.acceptWebSocket(server, ["chat"]);
// per-ws のメタデータを attach (2,048 byte 以内、JSON 化可能なもの)
const userId = crypto.randomUUID().slice(0, 8);
server.serializeAttachment({ userId, joinedAt: Date.now() });
// ping は DO を起こさずに pong を返す (keepalive 用途)
this.ctx.setWebSocketAutoResponse(
new WebSocketRequestResponsePair("ping", "pong")
);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
const meta = ws.deserializeAttachment() as
| { userId: string; joinedAt: number }
| null;
const text = typeof message === "string" ? message : "(binary)";
const payload = JSON.stringify({
from: meta?.userId ?? "anon",
text,
at: Date.now(),
});
// getWebSockets は hibernation 中の ws も含めて全件返す
for (const peer of this.ctx.getWebSockets("chat")) {
try {
peer.send(payload);
} catch {
// 送信失敗は webSocketClose 側で回収されるので無視
}
}
}
async webSocketClose(
ws: WebSocket,
code: number,
_reason: string,
wasClean: boolean
) {
// graceful / hard 両方で resource を解放
ws.close(code, wasClean ? "bye" : "closed");
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "WebWorker"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*"]
}
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "r43lab-do-hibernation-chat",
"main": "src/index.ts",
"compatibility_date": "2026-04-07",
"durable_objects": {
"bindings": [
{
"name": "ROOM",
"class_name": "ChatRoom"
}
]
},
// DO の初回デプロイで必須。クラス追加 / リネームの度にここを追記する。
// 2026 年現在の公式推奨は SQLite backend。Free プランは SQLite backend のみ。
// レガシーの Standard backend を使う場合のみ new_classes を使う。
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["ChatRoom"]
}
],
// 静的な client.html を配信するため assets を宣言 (任意)
"assets": {
"directory": "./public",
"binding": "ASSETS"
},
"observability": {
"enabled": true
}
}