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秒のアイドルの前後を表にします。

項目アイドル前アイドル後置かれていた場所
クライアント側の readyState11接続
getWebSockets().length11ランタイム
messagesSeenInMemory51クラスのフィールド、失われた
objectAgeMs412ms4msクラスのフィールド、失われた
nickInMemory"kept-in-a-field"nullクラスのフィールド、失われた
constructions12ストレージ、残った。オブジェクトが作り直されたことを示す
serializeAttachment() で保存した nick"changed-and-saved""changed-and-saved"アタッチメント、残った
ソケット受け入れ時に保存した joinedAt同じ値同じ値アタッチメント、残った
serializeAttachment() を呼ばない mutate メッセージ後の nick次のメッセージで "anon" のまま見直していない(あとの save で上書きした)変更は deserializeAttachment() が返したオブジェクトの中にしかなかった
自動応答が返した "ping"pongpong、コンストラクターの回数は1のままランタイムが応答

この表で、あえて口に出したい点が4つあります。

  1. クライアントは何にも気づきませんでした。 ソケットは OPEN のままで、pingには pong が返りました。再接続もイベントもありません。ハイバネーションの唯一の痕跡は、サーバーのログにある、コンストラクターの回数の増加だけです。
  2. 自動応答はオブジェクトを起こしませんでした。 setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong")) により、ランタイムが僕らのコードを動かさずに応答しました。このAPIでアプリ層のキープアライブを実装する、安上がりな方法です。ドキュメントでは、リクエストとレスポンスはそれぞれ2,048文字までです(DurableObjectState)。それより長いものは試していません。
  3. 変更は書き戻す必要があります。 deserializeAttachment() で値を得て、書き換えても、何も保存されませんでした。ドキュメントも同じことを書いています。「このメソッドを呼んだあとの値の変更は、もう一度呼ばない限り保持されない」。
  4. アタッチメントには硬い上限があり、すぐ失敗します。 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回きりの処理は、ストレージのフラグの後ろに置きます。
  • 賑やかなルームだけでテストする。 症状: デモでは動くのに、昼休みのあとで壊れる。直し方: アイドルの規則より長く眠るテストを必ず入れます。

ローカルで動かす

  1. Node 22の作業ディレクトリを作り、npm i wrangler ws して、上の4つのファイルを保存します。
  2. 示したとおり開発サーバーを起動し、出力を wrangler.log に書かせます。
  3. node client.mjs 15 "?room=a1" を実行します。成功なら、「後」の行が messagesSeenInMemory 1、nickInMemory null、constructions 2、nick はそのままです。
  4. node client.mjs 15 "?room=a2&timer=1" を実行します。成功なら、「後」の行が messagesSeenInMemory 7で、コンストラクターの回数は1のままです。
  5. node client.mjs 15 "?room=a3&std=1" を実行します。成功なら、ハイバネーションしません。
  6. node frame_ping.mjs a4 を実行します。成功なら、pongが3つで、回数は1のままです。
  7. 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のクラスのフィールドは、すべて「次のメッセージでは空かもしれないキャッシュ」として扱います。ソケットは残り、書き留めた状態は残り、それ以外は残りません。