TL;DR
- Hibernation APIを使うDurable Objectは、アイドルになるとメモリから取り除かれ、WebSocket接続は維持されます。次のメッセージが届くと、新しいインスタンスでコンストラクターがもう一度実行されます。ローカルの実行では、メッセージ数を数えるフィールドは5から1に戻り、経過時間のカウンターは412msから4msに戻り、ニックネームを持つフィールドは値から
nullに戻りました。 - 残る状態は、ストレージにあるものと、
serializeAttachment()で保存したものです。deserializeAttachment()が返したオブジェクトを書き換えても、もう一度serializeAttachment()を呼ばなければ何も保存されませんでした。 - アタッチメントの上限は16,384バイトです。20KBの値は、後からではなく、呼び出し時点で例外になりました。
- アプリケーション層の
"ping"をsetWebSocketAutoResponse()で返しても、オブジェクトは起きませんでした(コンストラクターの回数は1のまま)。WebSocketのプロトコルのpingフレームを3回送っても同じでした。 - ハイバネーションは自動ではありません。保留中の
setIntervalがあると、15秒のテストの間ずっとメモリに残り、標準のaccept()APIを使った場合も同じでした。 - すべて
wrangler dev --local(ローカルのworkerd)で動かしました。Cloudflareの本番ランタイム、課金、アラーム、70〜140秒の退避は試していません。
1つの文、2通りの読み方
Durable Objectsのドキュメントのライフサイクルのページは、ハイバネーション状態を1行で説明しています。「Durable Objectはメモリから取り除かれる。ハイバネーションされたWebSocket接続は接続されたままになる」。数行あとには注意書きがあります。「ハイバネーション中はメモリ上の状態が破棄されるので、重要な情報はすべてDurable Objectのストレージに保存すること」(Lifecycle of a Durable Object)。
さっと読むと、これはいい契約に見えます。接続は維持され、状態は自分で保存する。しかし、「このWebSocketはまだつながっている」ことと「このオブジェクトは、相手が誰かをまだ覚えている」ことの違いが、バグの出どころです。両方を実際に見たくて、それを見せられる最小のオブジェクトを作りました。
ドキュメントには、ハイバネーションの条件が並んでいます。setTimeout や setInterval のコールバックが予約されていないこと。未完了のI/Oや waitUntil() のPromiseがなく、開いたままの外向きの接続がないこと。標準のWebSocket APIを使っていないこと。処理中のリクエストやイベントがないこと。これらがすべて成り立ち、10秒間イベントがなければ、ハイバネーションします。1つでも偽なら、アイドル状態のままメモリに残り、70〜140秒の無活動のあと完全に退避されます。「10秒」は現時点の挙動で、ランタイムが決めるものとされています。
違いを見せる最小のオブジェクト
下のDurable Objectは、状態が置かれうる場所にそれぞれ1つずつ値を持たせてあり、1つのJSON応答でどれが生き残るかが分かります。
messagesSeenInMemory、nickInMemory、bornAt: 普通のクラスのフィールド(メモリだけ)constructions: オブジェクトのストレージ上のカウンターで、コンストラクターで増やします。オブジェクトが何回作られたかを数えます。- 各ソケットのアタッチメント:
joinedAtとnick getWebSockets().length: ランタイムがまだ知っているソケット
import { DurableObject } from "cloudflare:workers";
export class Room extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.bornAt = Date.now(); // in memory only
this.nickInMemory = null; // in memory only
this.messagesSeenInMemory = 0; // in memory only
this.constructions = (ctx.storage.kv.get("constructions") ?? 0) + 1; // in storage: survives
ctx.storage.kv.put("constructions", this.constructions);
console.log(`[room ${ctx.id.name}] constructor ran (#${this.constructions})`);
// answer the application-level "ping" without running our code
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
}
async fetch(request) {
const [client, server] = Object.values(new WebSocketPair());
if (new URL(request.url).searchParams.has("std")) { // the standard API instead: server.accept()
server.accept();
server.addEventListener("message", () => server.send(JSON.stringify({ objectAgeMs: Date.now() - this.bornAt, constructions: this.constructions })));
return new Response(null, { status: 101, webSocket: client });
}
this.ctx.acceptWebSocket(server); // the hibernation API
server.serializeAttachment({ joinedAt: Date.now(), nick: "anon" }); // per-connection state that survives
if (new URL(request.url).searchParams.has("timer")) setInterval(() => {}, 1000); // a pending timer
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws, message) {
this.messagesSeenInMemory++;
const att = ws.deserializeAttachment();
if (message === "mutate") att.nick = "changed-but-not-saved"; // no serializeAttachment() call
if (message === "save") { att.nick = "changed-and-saved"; ws.serializeAttachment(att); }
if (message === "inmem") this.nickInMemory = "kept-in-a-field";
if (message === "big") {
try { ws.serializeAttachment({ blob: "x".repeat(20000) }); ws.send("big: accepted"); }
catch (e) { ws.send(`big: ${e.name}: ${e.message}`); }
return;
}
ws.send(JSON.stringify({
messagesSeenInMemory: this.messagesSeenInMemory, // resets after hibernation
objectAgeMs: Date.now() - this.bornAt, // resets after hibernation
nickInMemory: this.nickInMemory, // resets after hibernation
constructions: this.constructions, // from storage: counts re-creations
nick: ws.deserializeAttachment().nick, // survives, if it was saved
joinedAt: att.joinedAt, // survives
sockets: this.ctx.getWebSockets().length, // the connections themselves survive
}));
}
async webSocketClose(ws, code) { console.log(`[room ${this.ctx.id.name}] close ${code}`); }
}
export default {
async fetch(request, env) {
const name = new URL(request.url).searchParams.get("room") ?? "lab";
return env.ROOM.getByName(name).fetch(request);
},
};
{
"name": "hib-lab",
"main": "src/index.js",
"compatibility_date": "2026-04-07",
"durable_objects": { "bindings": [{ "name": "ROOM", "class_name": "Room" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }]
}
クライアントは、いくつか質問し、15秒(10秒のアイドル規則より長い)待ち、もう一度質問します。コンストラクターが何回走ったかは、開発サーバーのログから読みます。
// usage: node client.mjs <idle-seconds> "<query>" e.g. node client.mjs 15 "?room=a"
import fs from "node:fs";
import WebSocket from "ws";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const idle = Number(process.argv[2] ?? 15), query = process.argv[3] ?? "?room=lab";
const room = new URLSearchParams(query).get("room");
const constructors = () => (fs.readFileSync("wrangler.log", "utf8").match(new RegExp(`\\[room ${room}\\] constructor`, "g")) || []).length;
const ws = new WebSocket(`ws://localhost:8787/${query}`);
const inbox = [];
ws.on("message", (m) => inbox.push(m.toString()));
await new Promise((r) => ws.on("open", r));
const ask = async (text) => { inbox.length = 0; ws.send(text); for (let i = 0; i < 50 && !inbox.length; i++) await sleep(100); return inbox[0]; };
const t0 = Date.now(); const at = () => `[${((Date.now() - t0) / 1000).toFixed(1).padStart(4)}s]`;
for (const m of ["hi", "mutate", "hi", "save", "inmem", "big", "ping"]) console.log(at(), m.padEnd(7), "->", await ask(m));
console.log(at(), `idle for ${idle} s; the socket stays open`);
await sleep(idle * 1000);
console.log(at(), "readyState", ws.readyState, "(1 = OPEN); constructor runs so far:", constructors());
console.log(at(), "ping ->", await ask("ping"), "| constructor runs now:", constructors());
console.log(at(), "hi ->", await ask("hi"), "| constructor runs now:", constructors());
console.log(at(), "big ->", await ask("big"));
ws.close();
セットアップです。僕の環境ではWrangler 4.147.0にNode 22が必要で(22.23.3を使いました)、作業ディレクトリにwranglerと ws を入れ、開発サーバーの出力を wrangler.log に書かせました。
mkdir do && cd do && npm init -y
npm i wrangler ws # wrangler 4.147.0、Node 22.23.3 で使用
# src/index.js と wrangler.jsonc を上のとおり保存し、client.mjs は package.json の隣に置く
npx wrangler dev --local --port 8787 > wrangler.log 2>&1 &
node client.mjs 15 "?room=a"
実行のたびに新しい room 名を使ってください。オブジェクトのストレージが、実行をまたいで constructions を保持するためです。
見えたこと
1回の実行の出力です(出力されたとおりの行を、重要なフィールドだけに切り詰めています)。
[ 0.0s] hi -> messagesSeenInMemory 1, objectAgeMs 9, nickInMemory null, constructions 1, nick "anon", sockets 1
[ 0.1s] mutate -> messagesSeenInMemory 2, ...
[ 0.2s] hi -> messagesSeenInMemory 3, ... nick "anon" (mutateは保存されていない)
[ 0.3s] save -> messagesSeenInMemory 4, ... nick "changed-and-saved"
[ 0.4s] inmem -> messagesSeenInMemory 5, objectAgeMs 412, nickInMemory "kept-in-a-field", constructions 1, nick "changed-and-saved", sockets 1
[ 0.5s] big -> big: Error: A WebSocket 'attachment' cannot be larger than 16384 bytes.'attachment' was 20015 bytes.
[ 0.6s] ping -> pong
[ 0.7s] idle for 15 s; the socket stays open
[15.7s] readyState 1 (1 = OPEN); constructor runs so far: 1
[15.7s] ping -> pong | constructor runs now: 1
[15.8s] hi -> messagesSeenInMemory 1, objectAgeMs 4, nickInMemory null, constructions 2, nick "changed-and-saved", sockets 1 | constructor runs now: 2
15秒のアイドルの前後を表にします。
| 項目 | アイドル前 | アイドル後 | 置かれていた場所 |
|---|---|---|---|
クライアント側の readyState | 1 | 1 | 接続 |
getWebSockets().length | 1 | 1 | ランタイム |
messagesSeenInMemory | 5 | 1 | クラスのフィールド、失われた |
objectAgeMs | 412ms | 4ms | クラスのフィールド、失われた |
nickInMemory | "kept-in-a-field" | null | クラスのフィールド、失われた |
constructions | 1 | 2 | ストレージ、残った。オブジェクトが作り直されたことを示す |
serializeAttachment() で保存した nick | "changed-and-saved" | "changed-and-saved" | アタッチメント、残った |
ソケット受け入れ時に保存した joinedAt | 同じ値 | 同じ値 | アタッチメント、残った |
serializeAttachment() を呼ばない mutate メッセージ後の nick | 次のメッセージで "anon" のまま | 見直していない(あとの save で上書きした) | 変更は deserializeAttachment() が返したオブジェクトの中にしかなかった |
自動応答が返した "ping" | pong | pong、コンストラクターの回数は1のまま | ランタイムが応答 |
この表で、あえて口に出したい点が4つあります。
- クライアントは何にも気づきませんでした。 ソケットは
OPENのままで、pingにはpongが返りました。再接続もイベントもありません。ハイバネーションの唯一の痕跡は、サーバーのログにある、コンストラクターの回数の増加だけです。 - 自動応答はオブジェクトを起こしませんでした。
setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"))により、ランタイムが僕らのコードを動かさずに応答しました。このAPIでアプリ層のキープアライブを実装する、安上がりな方法です。ドキュメントでは、リクエストとレスポンスはそれぞれ2,048文字までです(DurableObjectState)。それより長いものは試していません。 - 変更は書き戻す必要があります。
deserializeAttachment()で値を得て、書き換えても、何も保存されませんでした。ドキュメントも同じことを書いています。「このメソッドを呼んだあとの値の変更は、もう一度呼ばない限り保持されない」。 - アタッチメントには硬い上限があり、すぐ失敗します。 20KBの値は、呼び出し時に
A WebSocket 'attachment' cannot be larger than 16384 bytesを投げました。大きな値には、ストレージに入れ、そのキーをアタッチメントに持たせるのがドキュメントの助言です(WebSocketsのベストプラクティス)。
プロトコルのpingと、アプリ層のping
2つ目の小さなクライアントは、同じ時間アイドルにした別の新しいルームに、WebSocketのプロトコルのpingフレームを3回送りました。
import fs from "node:fs";
import WebSocket from "ws";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const room = process.argv[2] ?? "d"; // use a room name you have not used before
const count = () => (fs.readFileSync("wrangler.log", "utf8").match(new RegExp(`\\[room ${room}\\] constructor`, "g")) || []).length;
const ws = new WebSocket(`ws://localhost:8787/?room=${room}`);
await new Promise((r) => ws.on("open", r));
await sleep(15000); // longer than the 10 s idle rule
console.log("constructor runs after 15 s idle:", count());
let pongs = 0; ws.on("pong", () => pongs++);
for (let i = 0; i < 3; i++) { ws.ping(); await sleep(300); }
console.log("protocol pong frames received:", pongs, "| constructor runs now:", count());
ws.send("hi"); await sleep(500);
console.log("after one real message, constructor runs:", count());
ws.close();
constructor runs after 15 s idle: 1
protocol pong frames received: 3 | constructor runs now: 1
after one real message, constructor runs: 2
フレームには応答があり、コンストラクターの回数は1のままで、2回目の生成は本物のメッセージのときだけでした。つまり、このローカルのランタイムでは、どちらのキープアライブでもオブジェクトは起きませんでした。プロトコルレベルのフレームについては、ドキュメントにも同じことが書かれています(「Ping/pong handling does not interrupt hibernation」。WebSockets best practices)。この実験はそれと一致しました。本番では試していません。設計がこれに依存するなら、デプロイ先で試してください。
オブジェクトを起こしたままにする2つの要因(望まないかもしれません)
さらに2つのルームを、同じ15秒のアイドルで試しました。
| 変種 | アイドル後 |
|---|---|
ソケット受け入れ時に保留中の setInterval(() => {}, 1000) をセット | ハイバネーションせず: objectAgeMs 15824、messagesSeenInMemory 7、nickInMemory 保持、コンストラクターの回数は1のまま |
acceptWebSocket() ではなく標準API(server.accept() と addEventListener) | ハイバネーションせず: objectAgeMs 15843、コンストラクターの回数は1のまま |
これはドキュメントの条件と合っています。忘れたタイマーや、もう1つのWebSocketの流儀があると、オブジェクトは常にメモリにいることになります。ドキュメントによれば、メモリ上でアイドルかつハイバネーション不可の状態は期間に対して課金されます(Lifecycle)。費用は測っていません。また、ドキュメントにある、保留中のI/Oと開いた外向きの接続が退避を妨げるという点は、試していません。
ハイバネーションはデプロイではない
ハイバネーションは接続を保ちます。シャットダウンは保ちません。同じライフサイクルのページは、シャットダウン(新しいデプロイ、ランタイムの更新、ホスティングの判断)では「WebSocketのリクエストは自動的に終了される」と書き、Durable Objectは「いつでもシャットダウンしうる」し、シャットダウンフックはないとしています。だからクライアントには再接続の仕組みが引き続き必要で、サーバーは重要な状態を少しずつ書き込む必要があります。デプロイは試していません。
ここから得られるルール
- コンストラクターは軽くし、初めての起動だと決めつけない。 ハイバネーションのたびに再実行されます。「ルームを作った」の通知に使ってはいけません。必要なものはストレージから読みます。
- ソケットごとの状態はアタッチメント、ルームごとの状態はストレージ。 クラスのフィールドはキャッシュで、どのメッセージでも空かもしれません。
- 変更のたびに再シリアライズする。 誰も変更だけして忘れないよう、1つのヘルパーにまとめます。
- アタッチメントは小さく。大きなものはストレージに入れ、そのキーをアタッチメントに持たせる。
- アプリ層のキープアライブは自動応答にする。 オブジェクトがそれらの間も眠れます。
- ハイバネーションさせたいオブジェクトでは、タイマーを使わず、外向きの接続も持たない。 代わりにプラットフォームのスケジューリング機能を使います(アラームは試していません)。
ハイバネーションが向く場面
| 状況 | 選択 |
|---|---|
| ほとんどアイドルの接続が多数(チャットルーム、在席、通知)で、接続ごとの状態が小さい | Hibernation API |
| ソケットがつながっている間、オブジェクト自身が定期的な処理をする必要がある(ゲームのティック、上流のポーリング) | タイマーがあるとメモリに残ります。意図して決め、期間課金を受け入れるか、定期処理を別のオブジェクトに分けます。 |
| このオブジェクトが別のサービスへの長寿命の外向きWebSocketを持つ | その接続が開いている間はハイバネーションできません。 |
| 接続ごとの状態が大きい | ストレージに入れ、キーをアタッチメントに |
静かな時間のあとにだけ出るバグ
- クラスのフィールドに状態を置く。 症状: 静かな時間のあとにカウンターが戻る、「誰がルームにいるか」が間違うか空になる。上で再現しました。直し方: ストレージかアタッチメントを使い、誰がつながっているかは
getWebSockets()を正とします。 - デシリアライズしたアタッチメントを書き換える。 症状: ニックネームやカーソルの変更が、動くのに静かな時間のあとに消える。再現しました。直し方: もう一度
serializeAttachment()を呼びます。 - 16KiBを超えるアタッチメント。 症状: 呼び出し時の例外。再現しました。直し方: ストレージとキーです。
- 残ったタイマーか標準API。 症状: オブジェクトがハイバネーションせず、期間の課金が増える。「ハイバネーションしない」部分だけ再現しました。直し方: 取り除くか、受け入れるかです。
- コンストラクターを「ルーム作成」イベントとして使う。 症状: アイドルのあとにウェルカムメッセージが繰り返される。上のコンストラクターの回数が理由を示しています。直し方: 1回きりの処理は、ストレージのフラグの後ろに置きます。
- 賑やかなルームだけでテストする。 症状: デモでは動くのに、昼休みのあとで壊れる。直し方: アイドルの規則より長く眠るテストを必ず入れます。
ローカルで動かす
- Node 22の作業ディレクトリを作り、
npm i wrangler wsして、上の4つのファイルを保存します。 - 示したとおり開発サーバーを起動し、出力を
wrangler.logに書かせます。 node client.mjs 15 "?room=a1"を実行します。成功なら、「後」の行がmessagesSeenInMemory1、nickInMemorynull、constructions2、nickはそのままです。node client.mjs 15 "?room=a2&timer=1"を実行します。成功なら、「後」の行がmessagesSeenInMemory7で、コンストラクターの回数は1のままです。node client.mjs 15 "?room=a3&std=1"を実行します。成功なら、ハイバネーションしません。node frame_ping.mjs a4を実行します。成功なら、pongが3つで、回数は1のままです。15を5に変え、ハイバネーションするかを見ます。先に予想してから、ドキュメントの10秒と比べてください。
ローカルのworkerdと本番の違い
確認できたこと: wrangler dev --local(wrangler 4.147.0、ローカルのworkerd、Node 22.23.3、互換性日付2026-04-07、Linux 6.12)で、「見えたこと」「プロトコルのpingと、アプリ層のping」「オブジェクトを起こしたままにする要因」のすべてを、新しいルーム名で1回ずつ、出力されたとおりに確認しました。16KiBのアタッチメント上限、2,048文字の自動応答の上限、ライフサイクルの条件、10秒と70〜140秒の数字、シャットダウンの記述は、書く際にCloudflareのドキュメントで読みました。
確認できていないこと: Cloudflareの本番ランタイム(ローカルのworkerdは本番と挙動が違うかもしれません。ドキュメントによれば、wrangler 3.13.2より前のローカル開発ではそもそもハイバネーションしなかったので、バージョンにも依存します)、課金、アラーム、タグ、70〜140秒の退避、ハイバネーション可能なソケット数の上限、シャットダウンとデプロイの挙動、1ルームに2接続以上の場合。15秒のアイドルは、ドキュメントの10秒を超えるように選んだ実験用の値です。ローカルのタイミングは、本番の約束ではありません。
フィールドはキャッシュ
ハイバネーションするDurable Objectのクラスのフィールドは、すべて「次のメッセージでは空かもしれないキャッシュ」として扱います。ソケットは残り、書き留めた状態は残り、それ以外は残りません。