跳到內容

WebSocket Hibernation:讓一萬條連線幾乎不花錢

查證日期
驗證環境wrangler@4.114.0·workerd@1.20260722.1·compatibility_date: 2026-07-24

WebSocket 在 Durable Objects 上有兩種寫法,程式碼長得差不多,帳單差好幾個數量級

原因是 DO 按 GB-秒計費(第 14 篇)。一個聊天室有 1,000 條閒置連線:

  • ws.accept():物件必須一直活著等訊息 → 24 小時都在計費
  • ctx.acceptWebSocket():物件可以被回收,連線由 runtime 保管 → 閒置時不計 duration

官方原文:

「Billable Duration (GB-s) charges do not accrue during hibernation」

這篇會實測兩種寫法的差別,並處理三個實務陷阱:

  1. 兩種寫法的 socket 互相看不見。 ctx.getWebSockets()ws.accept() 建立的連線回傳 0
  2. setTimeout / setInterval 會悄悄毀掉 hibernation。
  3. 記憶體狀態會消失,而連線還活著。 我在本機重現了這個情況。

// ❌ Classic. The object must stay resident to receive events.
server.accept();
server.addEventListener("message", (e) => { /* ... */ });
// ✅ Hibernatable. The runtime holds the connection; the object can be evicted.
this.ctx.acceptWebSocket(server, ["user:alice", "room:general"]);

用了 acceptWebSocket() 之後,訊息不再走 addEventListener,而是走類別上的處理器方法

async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { }
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) { }
async webSocketError(ws: WebSocket, error: unknown) { }

官方那句關鍵:

「Unlike ws.accept(), state.acceptWebSocket(ws) allows the Durable Object to be hibernated」

🔴 兩種寫法的 socket 是兩個世界

Section titled “🔴 兩種寫法的 socket 是兩個世界”

實測一個用 ws.accept() 的房間:

Terminal window
$ curl -s localhost:8787/classic
{ "inMemorySet": 1, "ctxGetWebSockets": 0 }

自己的 Set 裡有 1 條連線,ctx.getWebSockets() 回傳 0。

ctx.getWebSockets() 只知道透過 acceptWebSocket() 註冊的連線。這代表:

  • 兩種寫法不能混用 —— 混用會得到一個「有些連線廣播得到、有些廣播不到」的系統。
  • 從 classic 遷移到 hibernation 是全有全無的改動。

getTags() 對沒註冊的 socket 也會 throw:

Terminal window
$ curl -s localhost:8787/foreigntags
{"threw":"Error: you must call 'acceptWebSocket()' before attempting to access the tags of a WebSocket."}

🔴 記憶體狀態會消失,連線會留著

Section titled “🔴 記憶體狀態會消失,連線會留著”

這是 hibernation 最需要內化的一點。實測:一條連線、送一則訊息、閒置 20 秒、再送一則:

Terminal window
t0 instanceAgeMs: 663 msgsSeen: 1
t+20s instanceAgeMs: 605 msgsSeen: 1
instance was recreated: true

instanceAgeMs 變小了(605 < 663)—— 這是一個更新的實例。而 messagesSeenByThisInstance 在第二則訊息之後仍然是 1,代表計數器被重置過。

同時,那條 WebSocket 從頭到尾沒有斷。 客戶端什麼都沒察覺。

(本機就能重現,不需要部署。官方對這個行為的說法是「When a Durable Object receives no events (such as alarms or messages) for a short period, it is evicted from memory」和「In-memory state is reset」。)

實務規則:

// ❌ This counter silently resets whenever the object is evicted.
private messageCount = 0;
// ✅ Anything that must survive goes into ctx.storage (chapter 15).
this.ctx.storage.sql.exec("UPDATE stats SET n = n + 1");

constructor 會在每次喚醒時重跑,所以 schema 建立、auto-response 設定這些都要放在 constructor 裡(它們本來就該在那)。

serializeAttachment():唯一的 per-connection 記憶

Section titled “serializeAttachment():唯一的 per-connection 記憶”

物件的記憶體會消失,但每條連線可以掛一小塊資料:

server.serializeAttachment({ user, room, joinedAt: Date.now() });
// ... later, possibly in a different instance ...
const att = ws.deserializeAttachment() as { user: string; room: string };

實測它撐過了物件重建 —— /inspect 在物件被回收重建之後仍然讀得到 { user: "carol", room: "private", joinedAt: ... }

大小上限 16 KiB,而且算的是序列化後的長度:

Terminal window
$ curl -s localhost:8787/attachment
{
"bytes=1024": { "ok": "accepted" },
"bytes=16000": { "ok": "accepted" },
"bytes=16384": { "threw": "Error: A WebSocket 'attachment' cannot be larger than 16384 bytes.'attachment' was 16398 bytes." },
"bytes=20000": { "threw": "Error: ... 'attachment' was 20014 bytes." }
}

注意 16,384 bytes 的內容失敗了,因為 {"pad":"..."} 的包裝多了 14 bytes。這和第 8 篇 KV metadata 的行為完全一樣 —— 算的是序列化後的總長度

設計原則:attachment 只放「識別這條連線是誰」所需的最小資料(user id、room id)。真正的資料放 ctx.storage,用 attachment 裡的 id 去查。

acceptWebSocket() 的第二個參數是 tags,之後可以按 tag 撈連線:

const sockets = this.ctx.getWebSockets("room:general");
for (const ws of sockets) ws.send(payload);

實測(alice 和 bob 在 room:general,carol 在 room:private):

Terminal window
$ curl -s "localhost:8787/broadcast?tag=room:general"
{"sent":2}
# carol received nothing
carol msgs: 0 []
bob msgs: ['pong', '{"broadcast":"hello all"}']
Terminal window
$ curl -s localhost:8787/inspect
{
"total": 3,
"byTag": { "alice": 1, "general": 2, "nonexistent": 0 },
"tagsOfFirst": ["user:carol", "room:private"],
"attachmentOfFirst": { "user": "carol", "room": "private", "joinedAt": 1785557296990 },
"autoResponseTimestampOfFirst": null,
"eventTimeout": null
}

限制:每條連線最多 10 個 tag,每個 tag 最多 256 字元

Tag 設計建議:放「你會用來廣播的維度」。典型是 user:{id}(送給特定使用者)+ room:{id}(送給群組)+ 可能的 role:{role}。不要放高基數又不會用來廣播的東西。

心跳是 hibernation 最大的敵人 —— 如果每 30 秒的 ping 都要喚醒物件,那就沒有省到。

ctx.setWebSocketAutoResponse(
new WebSocketRequestResponsePair("ping", "pong"),
);

實測 bob 送出 "ping"

bob ping -> pong

而且同一時間 /inspect 顯示 messagesSeenByThisInstance 仍然是 1(只有 alice 那則)——

webSocketMessage 完全沒有被呼叫。 runtime 直接回了 pong,物件連醒都沒醒。

其他細節:

  • 請求與回應字串各限 2,048 字元
  • ctx.getWebSocketAutoResponseTimestamp(ws) 可以查該連線最後一次自動回應的時間 —— 這是你判斷連線是否還活著的方式,因為 auto-response 不會留下任何其他痕跡。
  • WebSocket 協定層的 ping/pong control frame 本來就不會觸發 webSocketMessage,也不影響 hibernation。 setWebSocketAutoResponse 是給「應用層自己送 "ping" 文字訊息」這種常見做法用的。
  • setWebSocketAutoResponse() 不帶參數會移除設定。

官方原文:

「Events such as alarms, incoming requests, and scheduled callbacks prevent hibernation. This includes setTimeout and setInterval usage.

這是「為什麼我的 hibernation 沒生效」的第一名原因。

// ❌ This single line keeps the object resident forever.
setInterval(() => this.cleanup(), 60_000);
// ✅ Use an alarm instead (chapter 17).
this.ctx.storage.setAlarm(Date.now() + 60_000);

alarm 也會喚醒物件,但它是事件驅動的 —— 物件在兩次 alarm 之間可以被回收。setInterval 則需要物件持續存在才能計時。

同樣的道理:任何在 constructor 裡起的長時間 Promise、任何持續的 polling,都會讓物件無法被回收。

計費:省的是 duration 不是 request

Section titled “計費:省的是 duration 不是 request”

要精確理解省到什麼:

HibernationClassic
每則訊息算一個 request✅ 一樣算✅ 一樣算
閒置時的 GB-s不計持續累積

官方原文:「Billable Duration (GB-s) charges do not accrue during hibernation」。

所以 hibernation 適合「訊息稀疏」的場景(官方用詞:「messages transmitted occasionally at sparse intervals」)。如果你的連線每秒都在傳訊息,物件本來就一直醒著,hibernation 省不到什麼 —— 但也不會更差。

粗略試算 1,000 條連線、每條每 5 分鐘一則訊息、跑一整天:

  • Classic:物件全天在線。以 128 MB 計,24 小時 ≈ 11,059 GB-s → 約 $0.14 / 天 / 房間
  • Hibernation:只有處理訊息時計費。1,000 × 288 則 = 288,000 則訊息,每則假設 10ms → 約 3,686 GB-s… 但實際上遠低於此,因為多則訊息會落在同一次喚醒裡。

真正的差別在閒置房間:一個沒人說話的房間,classic 是 $0.14/天,hibernation 是 $0。有 1,000 個房間、其中 900 個閒置時,差距就是全部。

項目
連線數 / 物件32,768
Tag / 連線10(每個 ≤ 256 字元)
Attachment16,384 bytes(序列化後)
Auto-response 請求 / 回應各 2,048 字元
接收訊息大小32 MiB(2025-10-31 起,原本 1 MiB)
Hibernatable event timeout最長 604,800,000 ms(7 天)

訊息大小的提升值得注意 —— 官方原文:「Workers, including those using Durable Objects and Browser Rendering, may now process WebSocket messages up to 32 MiB in size. Previously, this limit was 1 MiB.」不需要 compat flag。但限制頁註明只適用於接收的訊息

getHibernatableWebSocketEventTimeout() 預設回傳 null(沒設定)。

⚠️ 一個會影響 close 行為的 compat flag

Section titled “⚠️ 一個會影響 close 行為的 compat flag”

web_socket_auto_reply_to_closecompatibility date 2026-04-07 起預設開啟:

「When a server sends a WebSocket Close frame, the Workers runtime now automatically sends a reciprocal Close frame.」

readyState 會在 close 事件之前先轉成 CLOSED,符合 WebSocket 規範。如果你在做 proxy 需要半開連線,accept() 有一個 allowHalfOpen 選項可以回到舊行為。

用舊 compat date 寫的 WebSocket 程式碼,webSocketClose 的觸發時機可能不同。


完整程式碼:examples/ch16-websocket-hibernation/

範例裡有兩個 DO class:HibernatingRoomacceptWebSocket)和 ClassicRoomws.accept),可以直接對照。

Terminal window
cd examples/ch16-websocket-hibernation && npm install && npm run dev

用 Node 內建的 WebSocket(Node ≥ 22)連進去:

const ws = new WebSocket("ws://localhost:8787/ws?user=alice&room=general");
await new Promise(r => ws.addEventListener("open", r));
ws.send("hello");
ws.addEventListener("message", (e) => console.log(e.data));
Terminal window
curl -s "localhost:8787/broadcast?tag=room:general" # 只送給 general
curl -s localhost:8787/inspect # tags、attachment、連線數
curl -s localhost:8787/attachment # 16 KiB 上限
curl -s localhost:8787/foreigntags # getTags 對未註冊 socket
curl -s localhost:8787/classic # ctxGetWebSockets: 0

重現物件被回收:連上一條 WebSocket、送一則訊息、閒置 20 秒、再送一則,然後比較 /inspectinstanceAgeMs。第二次會比第一次小 —— 那是新的實例,而連線從未中斷。


第 14 篇決定了即時看板的切分鍵是 dashboard:{tenantId}。這一篇把它實作出來。

一個租戶的儀表板需要看到該租戶所有連結的即時點擊。如果切成 per-link,前端要開幾百條 WebSocket。per-tenant 讓廣播天然收斂。

而閒置率極高 —— 大部分租戶大部分時間沒有人開著儀表板。這正是 hibernation 的理想場景。

this.ctx.acceptWebSocket(server, [
`tenant:${tenantId}`, // everyone in this dashboard
`user:${userId}`, // targeted messages (quota warnings, etc.)
]);
server.serializeAttachment({ userId, tenantId, since: Date.now() });

只有兩個 tag,都是會用來廣播的維度。 attachment 只放 id,不放使用者資料 —— 需要名字就用 userId 去 D1 查(第 9 篇)。

點擊事件不是直接進 LiveDashboard。路徑是:

redirect Worker
└─► LinkCounter DO(第 15 篇,per-link,累加)
└─► alarm 每 30 秒(第 17 篇)
├─► 寫 D1 rollup
└─► RPC 通知 LiveDashboard DO 廣播

為什麼不讓 redirect 直接打 LiveDashboard? 因為那會讓一個租戶的所有點擊集中到單一物件 —— 第 14 篇那個每秒 1,000 的軟上限。經由 per-link 的 LinkCounter 聚合之後再通知,把速率壓到每 30 秒一次。

前端每 45 秒送一次 "ping"

ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));

物件不會醒。判斷連線健康度用 getWebSocketAutoResponseTimestamp()

async pruneStale(): Promise<number> {
const cutoff = Date.now() - 120_000;
let closed = 0;
for (const ws of this.ctx.getWebSockets()) {
const last = this.ctx.getWebSocketAutoResponseTimestamp(ws);
if (last && last.getTime() < cutoff) { ws.close(1001, "stale"); closed++; }
}
return closed;
}

這個方法由 alarm 呼叫(第 17 篇),不是 setInterval —— 否則整個 hibernation 就白做了。

  1. 絕對不用 setInterval / setTimeout 週期性工作一律 alarm。
  2. 不依賴 instance 欄位。 任何需要跨訊息存活的東西進 ctx.storage
  3. 不混用兩種 accept。 ctx.getWebSockets() 看不到 ws.accept() 的連線。

本篇交付物LiveDashboard DO(hibernation、tag 廣播、auto-response、stale 清理)、前端的 WebSocket client wrapper(自動重連 + 心跳)、以及上面三條 checklist。


① 用 ws.accept() 卻期待 hibernation

要用 ctx.acceptWebSocket() + 處理器方法。

② 混用兩種寫法

ctx.getWebSockets() 對 classic socket 回傳 0。廣播會漏人。

③ 用 setInterval 做心跳或清理

官方明說會阻止 hibernation。改用 alarm。

④ 把狀態放在 instance 欄位

物件被回收就沒了,而連線還活著 —— 所以你不會馬上發現。

⑤ attachment 塞太多

16,384 bytes 是序列化後的上限。只放 id。

⑥ tag 用高基數又不廣播的值

每條連線最多 10 個 tag。

⑦ 以為 hibernation 省 request 費用

每則訊息一樣算一個 request。省的是 GB-s。

⑧ 沒處理 webSocketClose

DB 裡的「線上使用者」列表會殘留。

⑨ 用舊 compat date 寫 close 邏輯

web_socket_auto_reply_to_close 自 2026-04-07 起預設,改變了 close 的時序。


  1. **ctx.acceptWebSocket() 而不是 ws.accept()。**兩者的 socket 互相看不見,而且只有前者能 hibernate。
  2. **記憶體會消失,連線會留著。**任何要活過去的東西進 ctx.storage,per-connection 的身分進 serializeAttachment()(≤16 KiB)。
  3. **setInterval 會毀掉一切。**週期性工作一律用 alarm。


下一篇17. DO Alarms:內建的排程與去抖動 —— 上一篇說「週期性工作用 alarm」,這一篇處理它的 at-least-once 語意,以及 2026 年一個會讓「自毀」模式靜默改變行為的 compat flag。