WebSocket Hibernation:讓一萬條連線幾乎不花錢
這篇要解決的問題
Section titled “這篇要解決的問題”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」
這篇會實測兩種寫法的差別,並處理三個實務陷阱:
- 兩種寫法的 socket 互相看不見。
ctx.getWebSockets()對ws.accept()建立的連線回傳 0。 setTimeout/setInterval會悄悄毀掉 hibernation。- 記憶體狀態會消失,而連線還活著。 我在本機重現了這個情況。
// ❌ 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() 的房間:
$ curl -s localhost:8787/classic{ "inMemorySet": 1, "ctxGetWebSockets": 0 }自己的 Set 裡有 1 條連線,ctx.getWebSockets() 回傳 0。
ctx.getWebSockets() 只知道透過 acceptWebSocket() 註冊的連線。這代表:
- 兩種寫法不能混用 —— 混用會得到一個「有些連線廣播得到、有些廣播不到」的系統。
- 從 classic 遷移到 hibernation 是全有全無的改動。
getTags() 對沒註冊的 socket 也會 throw:
$ curl -s localhost:8787/foreigntags{"threw":"Error: you must call 'acceptWebSocket()' before attempting to access the tags of a WebSocket."}🔴 記憶體狀態會消失,連線會留著
Section titled “🔴 記憶體狀態會消失,連線會留著”這是 hibernation 最需要內化的一點。實測:一條連線、送一則訊息、閒置 20 秒、再送一則:
t0 instanceAgeMs: 663 msgsSeen: 1t+20s instanceAgeMs: 605 msgsSeen: 1instance was recreated: trueinstanceAgeMs 變小了(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,而且算的是序列化後的長度:
$ 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 去查。
Tags:廣播的基礎
Section titled “Tags:廣播的基礎”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):
$ curl -s "localhost:8787/broadcast?tag=room:general"{"sent":2}
# carol received nothingcarol msgs: 0 []bob msgs: ['pong', '{"broadcast":"hello all"}']$ 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}。不要放高基數又不會用來廣播的東西。
Auto-response:不喚醒物件的心跳
Section titled “Auto-response:不喚醒物件的心跳”心跳是 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()不帶參數會移除設定。
🔴 什麼會毀掉 hibernation
Section titled “🔴 什麼會毀掉 hibernation”官方原文:
「Events such as alarms, incoming requests, and scheduled callbacks prevent hibernation. This includes
setTimeoutandsetIntervalusage.」
這是「為什麼我的 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”要精確理解省到什麼:
| Hibernation | Classic | |
|---|---|---|
| 每則訊息算一個 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 字元) |
| Attachment | 16,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_close 自 compatibility 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:HibernatingRoom(acceptWebSocket)和 ClassicRoom(ws.accept),可以直接對照。
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));curl -s "localhost:8787/broadcast?tag=room:general" # 只送給 generalcurl -s localhost:8787/inspect # tags、attachment、連線數curl -s localhost:8787/attachment # 16 KiB 上限curl -s localhost:8787/foreigntags # getTags 對未註冊 socketcurl -s localhost:8787/classic # ctxGetWebSockets: 0重現物件被回收:連上一條 WebSocket、送一則訊息、閒置 20 秒、再送一則,然後比較 /inspect 的 instanceAgeMs。第二次會比第一次小 —— 那是新的實例,而連線從未中斷。
接進 LinkForge
Section titled “接進 LinkForge”第 14 篇決定了即時看板的切分鍵是 dashboard:{tenantId}。這一篇把它實作出來。
為什麼是 per-tenant
Section titled “為什麼是 per-tenant”一個租戶的儀表板需要看到該租戶所有連結的即時點擊。如果切成 per-link,前端要開幾百條 WebSocket。per-tenant 讓廣播天然收斂。
而閒置率極高 —— 大部分租戶大部分時間沒有人開著儀表板。這正是 hibernation 的理想場景。
Tags 設計
Section titled “Tags 設計”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 就白做了。
三條寫進 review checklist 的規則
Section titled “三條寫進 review checklist 的規則”- 絕對不用
setInterval/setTimeout。 週期性工作一律 alarm。 - 不依賴 instance 欄位。 任何需要跨訊息存活的東西進
ctx.storage。 - 不混用兩種 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 的時序。
本篇要記住的三句話
Section titled “本篇要記住的三句話”- **
ctx.acceptWebSocket()而不是ws.accept()。**兩者的 socket 互相看不見,而且只有前者能 hibernate。 - **記憶體會消失,連線會留著。**任何要活過去的東西進
ctx.storage,per-connection 的身分進serializeAttachment()(≤16 KiB)。 - **
setInterval會毀掉一切。**週期性工作一律用 alarm。
- Use WebSockets(best practices)
- WebSockets API
- DurableObjectState
- WebSocket 訊息上限提升到 32 MiB(2025-10-31)
- 計費
下一篇:17. DO Alarms:內建的排程與去抖動 —— 上一篇說「週期性工作用 alarm」,這一篇處理它的 at-least-once 語意,以及 2026 年一個會讓「自毀」模式靜默改變行為的 compat flag。