Cloudflare Durable Objects の Hibernation API 実践 — 長時間 WebSocket を安価に運用する

Durable Objects の WebSocket Hibernation API を使って、長時間接続でも Duration 課金が積み上がらない最小チャットサーバを組む方法。ctx.acceptWebSocket と 2 つのハンドラ、serializeAttachment、setWebSocketAutoResponse を、実際に動くコードでまとめます。

個人開発で 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() の代替)。tagsstring[] で任意
  • クラスメソッドとして定義するイベントハンドラ:
    • 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 に逃がす
  • setWebSocketAutoResponse1 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 を作った経験の再解説を書く予定です。

参考

記事で登場したコードをひとつの最小リポジトリとしてまとめました。ファイルツリーから切り替えて、全体の構造をそのまま確認できます。

$cat./src/index.tsts
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;
slugdurable-objects-hibernation-chatfiles8click a file in the tree to switch