WebSocket heartbeat:用應用層 ping/pong 偵測並清掉死連線
文字解析 · 1 支影片 · 產生於 2026-10-01 00:54
章節(13)
1. Outline
- 起點 · Keep Those WebSocket Connections Alive!
從「死連線讓客戶永遠流失」出發,在 Node.js ws 範本上逐步實作 server 端 setInterval ping/terminate 與 client 端 setTimeout 看門狗,最後多開分頁驗證 pong 數等於連線數。
2. YouTuber 的思維推導
Keep Those WebSocket Connections Alive!
Covalence · 23m03s · 字幕 en · vision=on (input 指定 vision=true。整支是 VS Code 實作錄影,程式碼只出現在畫面上、口述常省略細節(變數名、型別宣告),看圖才能對上。) · YouTube
預估 vs 實際耗時
| 階段 | 預估 | 實際 |
|---|---|---|
| fetch | 6s | 2s |
| segment | 1m16s | 1m58s |
| shot | 57s | 18s |
| analyze | 9m00s | 7m06s |
| render | 5s | – |
作者從一個很具體的痛點出發:WebSocket 任一端掉線時,另一端常常不知道,訊息就一直送進黑洞——用客服工單系統當例子,代價是「永遠失去一個客戶」。既然問題是「不知道對方還在不在」,解法就是定期問一次:server 每隔固定時間對每條連線送一個 ping,client 收到就回一個 pong;一輪之內沒回的就當死掉。要落地這個想法,他先把既有範本的 socket 邏輯搬到獨立檔案(sockets/index.ts)當工作區,接著在 server 端建立三個零件:常數(HEARTBEAT_INTERVAL、HEARTBEAT_VALUE)、對每條連線標記 isAlive 的狀態位(連線時 true),以及一個 setInterval:每輪先把還是 false 的連線 terminate 掉,其餘設成 false 再 ping;收到 binary 且值相符的訊息就把 isAlive 設回 true。
這形成「true → false + ping → pong → true」的閉環。然後他把視角換到 client:client 沒辦法主動 ping server,但可以反過來「等 server 的 ping」——每次收到 ping 就重設一個比 server 間隔略長(5 + 1 秒緩衝)的 setTimeout,同時回一個 Uint8Array 的 pong;timeout 真的觸發代表 server 太久沒來,就主動 close 並交給重連邏輯。過程中兩次碰到 TypeScript 型別不允許加自訂屬性,分別用 module augmentation(server 的 ws 套件)與 interface extends + as 斷言(瀏覽器原生 WebSocket)解決;client 端還需要用 Object.prototype.toString.call 判斷收到的是 Blob(ping)還是一般訊息。
最後用三個分頁實測「pong 數 = 活著的連線數」,並臨時補上 server 關閉時 clearInterval 的清理。結論回到開頭:正式環境把間隔拉到 10–30 秒,overhead 很小但必要,因為另一個選項是失去客戶。
推理鏈:每段留給下一段的線索
每一列是一段,箭頭後的字是這段留給下一段的線索。點段落標題跳到該段。
- 死連線問題與 ping/pong 解法 → 解法定了:server 要定期 ping、client 要回 pong。但作者手上的範本把所有 socket 邏輯都塞在主檔 index.ts 裡,加 heartbeat 之前得先有個乾淨的地方寫——下一段先做搬家。
- 重構:socket 邏輯搬到 sockets/index.ts → socket 邏輯現在全在 configure 函式裡,也知道 wss.clients 是所有連線的集合。下一步要決定 heartbeat 的具體參數:多久 ping 一次、ping 送什麼、怎麼送。
- 設計 heartbeat:間隔、值、ping 函式 → ping 函式有了,但 server 還沒辦法記住「這條連線上一輪有沒有回」。下一段要給每條連線一個狀態位 isAlive,並解決 TypeScript 不讓你在 ws 套件的 WebSocket 上加屬性的問題。
- 標記 isAlive 並擴充 ws 型別 → 每條連線現在都有 isAlive 且一開始是 true。接下來要寫真正的 setInterval:每一輪怎麼用這個狀態位決定「誰該被踢掉、誰該被 ping」。
- server 端 interval:不活就 terminate → server 現在每輪會 ping 並把 isAlive 放下,但還沒有任何程式碼把它舉回 true——pong 到底長什麼樣、在哪裡接?下一段在 message 處理器裡補上另一半。
- 收 pong:binary 且值相符才算活著 → server 端閉環完成:ping 出去、pong 回來就續命、沒回來就踢掉。但 client 到現在什麼都還沒做——它收到 binary 的 ping 之後要回什麼?而且 client 自己也該察覺「server 太久沒 ping 我」。下一段換到瀏覽器端。
- client 端 heartbeat:timeout 加緩衝 → heartbeat() 現在會設一個逾時關閉的計時器,但每次收到 ping 都 setTimeout 會累積出一堆舊計時器,而且 TypeScript 還在對 ws.pingTimeout 畫紅線。下一段要先清掉舊 timeout,再解決瀏覽器原生 WebSocket 的型別問題。
- clearTimeout 與 WebSocketExt 型別 → heartbeat() 現在會清舊 timeout、設新 timeout,型別也安靜了。但它還沒回 pong 給 server——server 那邊等的是一個 data[0] === 1 的 binary 訊息,client 要怎麼在瀏覽器裡造出這種東西?
- 補上 pong:Uint8Array 送回 server → client 會回 pong 了,但要先能分辨「收到的是 ping 還是要顯示的訊息」才知道何時該呼叫 heartbeat()。瀏覽器收到 binary 時給的是什麼物件?下一段寫 isBinary()。
- isBinary:用 toString 判斷 Blob → 工具齊了:heartbeat() 負責計時與回 pong,isBinary() 負責分流。但它們都還沒被接到任何事件上——onmessage 收到 ping 時要呼叫 heartbeat(),onclose 時該把 timeout 清掉。下一段把零件接起來。
- 接上 onclose / onmessage → server 與 client 兩邊的程式都寫完了,但還沒真的跑過:多開幾條連線時 pong 數會不會對?關掉分頁後 server 會不會少算一個?下一段實測。
- 實測多連線與 server close 清理 → 機制驗證通過,最後回到一開始的取捨:示範用的 5 秒在正式環境該怎麼調、這個 overhead 到底值不值得?
- 總結:間隔調大,開銷小但必要
3. 逐段說明
Keep Those WebSocket Connections Alive!
1. 死連線問題與 ping/pong 解法 0:00–1:02
作者先講痛點:WebSocket 不管誰掉線,client 與 server 常常都不知道,訊息就這樣一直送進黑洞。他用客服工單系統當例子——客戶得自己猜要不要重新整理、整個支援流程重來,一個客戶就永遠流失了。結論是要用 ping/pong 機制定期確認每條連線還活著。
推理因為 WebSocket 兩端都可能在不知情的狀況下掉線,所以作者先用客服工單系統把代價講具體:客戶或客服其中一方斷線、雙方都沒察覺,訊息一直送卻永遠沒回音,客戶只能自己猜要不要重新整理、整個流程重來,結果是「永遠失去一個客戶」。問題的本質是「不知道對方還在不在」,所以解法自然是定期確認:用 ping/pong 機制,每隔一段時間問一次所有連線是否還活著。
- Cdead connection:底層已斷、應用層仍以為連著的 WebSocket,訊息一直送卻永遠沒回音→ 畫地圖
- E客服工單案例:一方掉線、雙方都沒察覺,客戶只能猜要不要重整,結果永遠失去一個客戶→ 存+演練
- E為什麼掉線了卻不知道:對方沒送 FIN/RST(切網路、闔筆電、NAT 清表)時,TCP 一直停在已連線→ 存+演練
- Cping/pong:一方定期送小訊息,另一方收到就回;一段時間沒回就當對方死掉→ 畫地圖
- R本片的 ping/pong 是應用層自己定義的一問一答,不是 WebSocket 協定內建的 ping/pong 控制訊框→ 存+回想
AI 補充作者沒解釋為什麼「掉線了卻不知道」會發生。原因是 WebSocket 建立在 TCP 上,而 TCP 在對方沒有正常送出 FIN/RST(例如手機切網路、筆電闔上、中間的 NAT 或 proxy 把閒置連線表項清掉)時,本端 socket 會一直停留在「已連線」狀態,直到你真的送資料且重試逾時(可能是好幾分鐘)才會報錯。應用層如果只是被動等訊息,就永遠不會知道。「ping/pong」在這支影片裡是**應用層**自己定義的一問一答,不是 WebSocket 協定內建的 ping/pong 控制訊框——這個差異在後面實作時會看得更清楚。
術語:dead connection
2. 重構:socket 邏輯搬到 sockets/index.ts 1:03–3:26
從既有的 websocket 範本專案出發,先做整理:新建 sockets/index.ts,把原本塞在 index.ts 裡 app.listen 之後的所有 WebSocket 邏輯剪過去,包成 export default function configure(server: Server)。主檔改成 configureSockets(app.listen(...)),把 http Server 物件整個傳進去。這步只是搬家,還沒加 heartbeat。

推理因為上一段決定要在既有範本上加 ping/pong,而範本目前所有東西都擠在主檔 index.ts 裡,所以作者先做純搬家:新建 sockets/index.ts,把原本 app.listen 之後的 WebSocket 邏輯全部剪過去,包成 export default function configure(server: Server);主檔改成 configureSockets(app.listen(...)),把 http Server 物件整個傳進去。這步不改行為,只是讓之後所有 server 端程式碼有一個固定的家。
- P先搬家不加功能:socket 邏輯抽成 sockets/index.ts 的 configure(server),主檔傳入 app.listen()→ 練習
- RWebSocketServer 用 noServer: true 建立,要自己接管 HTTP 'upgrade' 事件呼叫 handleUpgrade→ 存+回想
AI 補充截圖(204 秒)補上口述沒講清楚的兩件事。第一,搬過去的不只是 connection 處理,還包含 s.on('upgrade') 裡的 wss.handleUpgrade 流程:server 是用 `new WebSocketServer({ noServer: true })` 建立、自己接管 HTTP upgrade 事件,所以 configure 才需要拿到整個 http Server 才能掛 'upgrade' 監聽。第二,既有的 message 處理已經是「收到訊息 → 對 wss.clients 裡 readyState === OPEN 的每個 client 廣播」,這個 forEach 迴圈之後會被 heartbeat 的 interval 直接仿照——先記住 wss.clients 這個集合。
術語:http.Server
3. 設計 heartbeat:間隔、值、ping 函式 3:27–5:50
作者說明機制:server 用 setInterval 每隔一段時間 ping 所有連線,期待收到 pong。有一點 overhead,但比堆一堆死連線便宜。定義 HEARTBEAT_INTERVAL(示範用 5 秒,正式建議 10–30 秒,看 client 可接受的最大延遲)與 HEARTBEAT_VALUE = 1(可以換成更不好猜的值),並寫 ping(ws) 函式:把 heartbeat value 以 binary 送出,不是字串或 JSON。

推理因為上一段已經有了乾淨的工作區,所以作者先把機制講白:用 setInterval 每隔一段時間對每條連線 ping,期待收到 pong;這有一點 overhead,但比堆一堆死連線便宜。接著定義 HEARTBEAT_INTERVAL = 1000 * 5(示範用 5 秒,正式建議 10–30 秒,依 client 可接受的最大延遲決定)與 HEARTBEAT_VALUE = 1(可以換成更不好猜的值),並寫 ping(ws) 函式:ws.send(HEARTBEAT_VALUE, { binary: true }),強調是以 binary 送、不是字串或 JSON。
- Cheartbeat:server 用 setInterval 每隔 HEARTBEAT_INTERVAL 對每條連線 ping,期待收到 pong 的整套機制→ 畫地圖
- RHEARTBEAT_INTERVAL 示範用 5 秒,正式建議 10–30 秒,依 client 可接受的最大延遲決定→ 存+回想
- Cbinary frame:用訊框型別區分 ping 與一般文字訊息,client 不必解析內容就能分流→ 畫地圖
- E小坑:ws.send(1, { binary: true }) 會先轉字串,實際送出 0x31('1')不是 0x01→ 存+演練
AI 補充為什麼堅持用 binary?因為之後 client 要能一眼分辨「這是 ping」還是「這是要顯示的訊息」,而一般聊天訊息是文字(字串或 JSON),把 ping 放在另一個訊框型別就不用去解析內容。截圖(345 秒)補上口述沒說的細節:`{ binary: true }` 是 ws 套件 send() 的選項,決定送出的 WebSocket 訊框是 binary 還是 text。不過這裡有個作者沒察覺的小坑:傳給 send 的是數字 1,ws 套件會先把數字轉成字串再包成訊框,所以實際送出去的位元組是 0x31(字元 '1'),不是 0x01。這支影片裡 client 只檢查「是不是 binary」不檢查值,所以還是能動;但如果 client 也想比對值,得改成 `ws.send(Buffer.from([HEARTBEAT_VALUE]), { binary: true })`。
術語:binary frame
「we're basically sending a one down and」
4. 標記 isAlive 並擴充 ws 型別 5:51–7:02
在 wss.on('connection') 拿到 socket 時,先設 ws.isAlive = true。TypeScript 抱怨 WebSocket 沒這個屬性,所以新建 typings/ws.d.ts:import WebSocket from 'ws',用 declare module 'ws' 擴充 interface WebSocket,加上 isAlive: boolean。作者習慣用被擴充的套件名來命名 d.ts 檔。

推理因為上一段的 ping 只是「問」,還需要一個地方記「答了沒」,所以作者選擇最直接的做法:把狀態掛在連線物件本身,連線一建立就 ws.isAlive = true。TypeScript 立刻抱怨 ws 套件的 WebSocket 型別沒有 isAlive,於是新建 typings/ws.d.ts:import WebSocket from 'ws',用 declare module 'ws' 裡的 interface WebSocket { isAlive: boolean } 把屬性「合併」進原本的型別。作者習慣用被擴充的套件名來命名這個 d.ts 檔。
- P給每條連線一個狀態位:connection 第一行 ws.isAlive = true,用 module augmentation 讓 TS 接受→ 練習
- Cmodule augmentation:.d.ts 先 import 模組,再 declare module 同名並宣告同名 interface 來合併屬性→ 畫地圖
- A狀態掛在 socket 物件上,而不是另開一個 Map 記錄→ 批判類比
AI 補充為什麼把狀態掛在 socket 上而不是另外開一個 Map?因為 wss.clients 已經是所有連線的集合,之後 interval 遍歷時直接讀 client.isAlive 最省事,連線被 terminate 從集合移除時狀態也跟著消失,不用另外清理。截圖(415 秒)確認初始值是在 connection 回呼的第一行設定,位置在 ws.on('error') 與 ws.on('message') 之前。至於 TypeScript 那步:declare module 'ws' 裡再宣告一次同名 interface 會與套件原本的 interface **合併**而不是覆蓋,這叫 module augmentation;先 import 'ws' 是必要的,否則 TypeScript 會把 declare module 當成「宣告一個全新的模組」,型別就整個被蓋掉。
術語:declaration file
5. server 端 interval:不活就 terminate 7:03–8:06
在 connection 處理器外面宣告 const interval = setInterval(..., HEARTBEAT_INTERVAL)。每次觸發就遍歷 wss.clients(不管 readyState):如果 client.isAlive 是 false 就 client.terminate() 並 return;否則把 isAlive 設回 false 再 ping(client),等 client 回 pong。

推理因為上一段已經讓每條連線帶著 isAlive,所以 interval 的邏輯可以很短:仿照 message 處理器那個 forEach,遍歷 wss.clients(但不管 readyState),如果 client.isAlive 是 false 就 client.terminate() 並 return;否則把 isAlive 設成 false,再 ping(client),等 client 回 pong 把它設回 true。作者特別把「先設 false 再 ping」講成一組動作。
- Pserver 端 interval:遍歷 wss.clients,isAlive false 就 terminate,否則先設 false 再 ping→ 練習
- E判定一條連線死掉需要一整個 interval,斷在剛 ping 完之後最多延遲兩個 interval 才被踢→ 存+演練
- R死連線用 terminate() 不用 close():close 會等對方回 close 訊框,死連線永遠等不到→ 存+回想
- Rheartbeat 的 forEach 不管 readyState:目標正是「readyState 還是 OPEN 但其實已死」的連線→ 存+回想
AI 補充「先設 false 再 ping」的順序是整個機制的核心:這一輪把旗子放下,下一輪來檢查旗子有沒有被 pong 舉回來——所以判定一條連線死掉需要**一整個 interval 的時間**,不是收到 pong 的瞬間。也因此一條連線從斷掉到被踢,最多會延遲兩個 interval(斷在剛 ping 完之後)。為什麼不管 readyState?因為 heartbeat 的目的就是抓那些「readyState 還是 OPEN 但其實已經死了」的連線,用 readyState 篩選反而會漏掉目標。為什麼用 terminate() 而不是 close()?close() 會走正常的關閉握手、等對方回 close 訊框——對死連線來說永遠等不到;terminate() 直接銷毀底層 socket,立刻從 wss.clients 移除。截圖(484 秒)確認 interval 宣告在 wss.on('connection') 區塊之後、仍在 configure 函式內,所以它拿得到 wss。
6. 收 pong:binary 且值相符才算活著 8:07–10:25
pong 會以 binary 的 message 進來。在 ws.on('message', (data, isBinary)) 裡:如果 isBinary 且 data[0] === HEARTBEAT_VALUE,就把 ws.isAlive 設回 true 並 log 'pong';否則才走原本廣播訊息的邏輯。也在 interval 裡 log 'firing interval' 方便觀察。作者總結整個循環:連線時 true → interval 設 false 並 ping → 收到 pong 設回 true;掉線就不會回 pong,下一輪就被 terminate、從 clients 清掉;不會有 race condition。
![message 處理器裡 isBinary && data[0] === HEARTBEAT_VALUE 的判斷與 isAlive = true,對照 interval 那段 false → ping 的另一半](/media/Keep%20Those%20WebSocket%20Connections%20Alive%21/frames/s06_560.jpg)
推理因為上一段的 interval 只負責放旗子,所以作者回到 ws.on('message', (data, isBinary)):如果 isBinary 且 data[0] === HEARTBEAT_VALUE,就把 ws.isAlive 設回 true 並 log 'pong';否則才走原本廣播的邏輯。也在 interval 開頭 log 'firing interval' 方便觀察。然後他把整個循環講一遍:連線時 true → interval 設 false 並 ping → 收到 pong 設回 true;掉線就不會回 pong,下一輪被 terminate、從 clients 清掉;他認為不會有 race condition。
- Rserver 收 pong:isBinary 且 data[0] === HEARTBEAT_VALUE 才把 isAlive 設回 true→ 存+回想
- E單執行緒 Node 裡 interval 與 message 回呼不會同時跑,沒有並發;但 RTT 超過一個 interval 會誤判活連線→ 存+演練
AI 補充data 在 ws 套件裡預設是 Buffer,data[0] 就是第一個位元組,所以能直接和 HEARTBEAT_VALUE 比對(作者先把它標成 any 省事)。這一段補上第 3 段留下的伏筆:這裡比對的是 client 送回來的 pong,而 client 之後會用 Uint8Array 放入數值 1,所以 data[0] === 1 成立——server 自己送出去的 ping 內容是不是 0x01 在這裡沒有影響。關於「不會有 race condition」:在單執行緒的 Node.js 裡,interval 回呼和 message 回呼不會同時執行,確實沒有並發衝突;但有一個時序邊界要知道——如果 client 回 pong 的來回時間超過一個 interval(例如間隔設太短或網路很慢),活著的連線也會被誤判為死。所以間隔不能小於預期的最差 RTT,這也是作者說正式環境用 10–30 秒的另一層理由。截圖(560 秒)是在 interval 開頭打 console.log 的瞬間,可以看到 interval 與 forEach 的最終結構。
7. client 端 heartbeat:timeout 加緩衝 10:26–13:26
到 public/app.js 寫 heartbeat() 函式(client 只有一個 ws,不用傳參數)。核心是 ws.pingTimeout = setTimeout(...),時間要跟 server 的間隔對上,但要多留一點緩衝:HEARTBEAT_TIMEOUT = 1000 * (5 + 1),把 5 秒與 1 秒 buffer 寫清楚而不是直接寫 6000,可讀性優先,這點運算成本可以忽略。timeout 真的觸發代表 server 太久沒 ping,就 ws.close(),接著是「要不要重連」的業務邏輯位置(自動重連或跳 modal 問使用者)。

推理因為上一段的 server 已經會定期送 ping,所以 client 的角色是被動的:每次收到 ping 就「重新起算」一個計時器,計時器真的響了就代表 server 太久沒來。作者原本想把 ws 當參數傳進 heartbeat,但想到 client 同一時間只有一個 ws,就改成無參數函式。核心是 ws.pingTimeout = setTimeout(...),時間必須和 server 的間隔對上但要多留緩衝:HEARTBEAT_TIMEOUT = 1000 * (5 + 1),把「5 秒」和「1 秒 buffer」寫清楚而不是直接寫 6000,可讀性優先、運算成本可忽略。timeout 觸發時 ws.close(),之後就是「要不要重連」的業務邏輯(自動重連或跳 modal 問使用者)。
- Pclient heartbeat():每次收到 ping 就重設 pingTimeout = setTimeout(close, 間隔 + 緩衝)→ 練習
- Aserver 是主動方用 setInterval 固定節奏出擊;client 是被動方用可重設的 setTimeout 當看門狗→ 批判類比
- Cbuffer (timing):client timeout 要比 server 間隔多留一段,吸收網路抖動,避免正常的 ping 晚幾十毫秒就被誤判→ 畫地圖
AI 補充為什麼 client 用 setTimeout 而 server 用 setInterval?因為 server 是主動方,要固定節奏出擊;client 是被動方,只需要一個「倒數計時器」,每次收到 ping 就歸零重來——這種「重設倒數」的模式用 setTimeout 最自然。為什麼要加緩衝?因為 server 的第 N 次 ping 和第 N+1 次 ping 中間隔 5 秒,再加上網路傳輸抖動,如果 client 的 timeout 也剛好 5 秒,正常的 ping 有機會晚個幾十毫秒到而被誤判;多 1 秒就吃掉這個抖動。這裡的 5 是複製 server 的常數值,兩邊沒有共用來源,改 server 時要記得同步改 client。截圖(770 秒)補上口述沒提的細節:前端檔案其實是 public/js/app.ts(TypeScript,不是段落摘要裡的 app.js),而且上面已經有一個 closeConnection() 函式在做 `if (!!ws) ws.close()`;ws.pingTimeout 在畫面上有紅色波浪線,那是 TypeScript 的抱怨,還沒處理。
術語:buffer (timing)
8. clearTimeout 與 WebSocketExt 型別 13:27–15:11
heartbeat() 開頭先防呆:if (!ws) return;if (ws.pingTimeout) clearTimeout(ws.pingTimeout),再重設新的 timeout。原生 WebSocket 沒有 pingTimeout 屬性,作者提醒這算污染原生物件,可考慮加底線前綴。解法是在 typings/index.d.ts 定義 interface WebSocketExt extends WebSocket { pingTimeout: NodeJS.Timeout }(setTimeout 回傳型別),建立 ws 時 as WebSocketExt。

推理因為上一段每次呼叫 heartbeat() 都會新建一個 timeout,所以作者在函式開頭先防呆:if (!ws) return;else if (!!ws.pingTimeout) clearTimeout(ws.pingTimeout),然後才設新的。接著處理紅線:原生 WebSocket 沒有 pingTimeout 屬性,作者承認這算污染原生物件,建議可以加底線前綴表示「這是我加的」。解法是在 typings/index.d.ts 定義 interface WebSocketExt extends WebSocket { pingTimeout: NodeJS.Timeout }(他 hover 看到 setTimeout 回傳型別是 NodeJS.Timeout 就照抄),建立連線時寫 new WebSocket(...) as WebSocketExt。
- Rheartbeat() 開頭要 clearTimeout 舊的再 set:不清的話第一個 timeout 6 秒後照樣響,連線會每 6 秒斷一次→ 存+回想
- C擴充瀏覽器內建 WebSocket 型別:WebSocketExt extends WebSocket,建立時 as WebSocketExt→ 畫地圖
- R兩種擴型別的差別:augmentation 改全域套件型別;extends + as 只影響你明確斷言的那個變數→ 存+回想
- RpingTimeout 型別照抄 NodeJS.Timeout 能過,可攜寫法是 ReturnType<typeof setTimeout>→ 存+回想
AI 補充為什麼不清舊 timeout 會出事?因為第一個 timeout 沒被取消的話,即使之後 ping 都正常到,6 秒後它還是會響並 ws.close()——連線會莫名其妙每 6 秒斷一次。所以「clear 再 set」不是優化而是正確性。型別那步和第 4 段是刻意的對照:server 端擴充的是 ws **套件**的型別,用 module augmentation 合併;client 端要擴充的是瀏覽器**內建**的 WebSocket,作者改用「宣告一個子介面 + as 斷言」——這不會改到全域的 WebSocket 型別,只有你明確斷言的那個變數才有 pingTimeout。兩種做法都只影響型別檢查,執行期一樣是直接在物件上加屬性。至於 NodeJS.Timeout:瀏覽器的 setTimeout 其實回傳 number,編輯器顯示 NodeJS.Timeout 是因為這個專案的前端 tsconfig 也看得到 @types/node,Node 的型別覆蓋了 DOM 的;照抄能過編譯,但更可攜的寫法是 ReturnType<typeof setTimeout>。截圖(908 秒)可以看到 timeout 回呼裡已補上 ws.close() 與重連註解,以及 as WebSocketExt 的位置。
術語:type assertioninterface extends
「actually see it is a node.js.timeout」
9. 補上 pong:Uint8Array 送回 server 15:12–16:40
作者發現 heartbeat() 還沒送 pong 就回頭補:建立 new Uint8Array(1),data[0] = HEARTBEAT_VALUE(client 也宣告一個 = 1 的常數),然後 ws.send(data)。至此 heartbeat 完成:設 timeout → 有舊的就清掉 → 重設 → 回 pong。若 server 一直沒 ping,timeout 就會觸發並手動 close 連線。

推理因為上一段的 heartbeat() 只做了計時,而第 6 段的 server 要收到 binary 且第一個位元組等於 HEARTBEAT_VALUE 才會把 isAlive 舉回 true,所以作者在 heartbeat() 尾端造一個長度 1 的 Uint8Array,data[0] = HEARTBEAT_VALUE(client 也宣告一個 = 1 的常數,他認為放上面比較清楚),然後 ws.send(data)。至此 heartbeat 完成:設 timeout → 有舊的就清掉 → 重設 → 回 pong;若 server 一直沒 ping,timeout 觸發就手動 close,之後可接重連。
AI 補充為什麼用 Uint8Array 而不是直接 ws.send(1)?瀏覽器的 WebSocket.send() 只接受 string、Blob、ArrayBuffer 或 ArrayBufferView;傳數字會被轉成字串 '1' 以 text 訊框送出,server 端的 isBinary 就是 false,pong 會被當成一般訊息廣播出去。Uint8Array 是 ArrayBufferView,瀏覽器會自動以 binary 訊框送出,不需要像 ws 套件那樣指定 { binary: true }。這也呼應第 3 段那個位元組的問題:client 這邊放進去的是真正的數值 1(0x01),所以 server 的 data[0] === 1 才會成立。截圖(972 秒)是整個 heartbeat() 的最終樣貌:防呆與 clearTimeout、setTimeout 含 ws.close()、Uint8Array pong,三件事一起看比較容易對上「client 每收到一次 ping 做哪些事」。
10. isBinary:用 toString 判斷 Blob 16:41–17:35
client 收到 message 要分辨是 binary 的 ping 還是要顯示的訊息。isBinary(obj) 的技巧:typeof obj === 'object' 且 Object.prototype.toString.call(obj) === '[object Blob]'。瀏覽器端 WebSocket 收到的 binary 預設是 Blob,所以是 Blob 就當 binary,否則是 JSON 或字串。
![Object.prototype.toString.call(obj) === '[object Blob]' 這行寫法,口述「grab the toString of Object prototype and call with the context」很難還原](/media/Keep%20Those%20WebSocket%20Connections%20Alive%21/frames/s10_1040.jpg)
推理因為上一段確定 server 的 ping 是 binary、一般訊息是文字,所以 client 只要判斷「這個 message.data 是不是 binary」。作者的技巧是:typeof obj === 'object' 且 Object.prototype.toString.call(obj) === '[object Blob]'——抓 Object.prototype 上的 toString,用 obj 當 this 去呼叫,等於對它做一次最原始的 toString。瀏覽器 WebSocket 收到的 binary 預設是 Blob,所以是 Blob 就當 binary,否則就是 JSON 或字串。
- RisBinary(obj):Object.prototype.toString.call(obj) === '[object Blob]' 判斷是不是 Blob→ 存+回想
- R前提:瀏覽器 binaryType 預設 'blob';設成 'arraybuffer' 就要改比對 ArrayBuffer→ 存+回想
AI 補充為什麼要繞到 Object.prototype.toString 而不是直接 obj instanceof Blob?兩者在這裡都可以;作者的寫法是一個通用技巧,優點是不受物件自己覆寫 toString 影響、也能跨 iframe/realm 使用(instanceof 在跨 realm 時會失敗)。它回傳的是內建的「[object 型別標籤]」字串,所以能分辨 Blob、ArrayBuffer、Date 等等。要注意的前提:瀏覽器 WebSocket 的 binaryType 預設是 'blob';如果專案把 ws.binaryType 設成 'arraybuffer',這裡就得改比對 '[object ArrayBuffer]'。另外 typeof 檢查嚴格說是多餘的(Blob 一定是 object),但先擋掉 string 再做 toString 比對是合理的防禦。截圖(1040 秒)確認函式簽名是 isBinary(obj: any) 且比對字串是 '[object Blob]',中間有空格、B 大寫。
11. 接上 onclose / onmessage 17:36–18:42
把工具接進事件:ws.onclose 裡若有 pingTimeout 就 clearTimeout,不再需要;ws.onmessage 裡 if (isBinary(message.data)) heartbeat() 否則照原本顯示訊息。heartbeat() 會回 pong 給 server,讓連線保持活著。client 端實作到此完成。

推理因為上一段已經有了分流工具,所以接線很直接:在 'close' 事件裡若有 ws.pingTimeout 就 clearTimeout——連線都關了,倒數不再需要;在 'message' 事件裡 if (isBinary(msg.data)) heartbeat() 否則照原本 showMessage。heartbeat() 會重設倒數並回 pong 給 server,讓 server 那邊的 isAlive 續命。client 端實作到此完成。
- E邊界:heartbeat() 只在第一個 ping 之後才被呼叫,連線建立到第一個 ping 之間 client 沒有看門狗→ 存+演練
- Ronclose 要 clearTimeout(ws.pingTimeout):連線已關,沒清的倒數 6 秒後會錯誤觸發重連邏輯→ 存+回想
AI 補充為什麼 close 時一定要清 timeout?因為如果連線是使用者自己關的(或 server 踢的),沒清掉的 timeout 6 秒後還是會響,對一個已關閉的 ws 再呼叫 close() 雖然不會拋錯,但重連的業務邏輯會被錯誤觸發。截圖(1120 秒)顯示的是最終接線:close 的 clearTimeout 放在 showMessage('WebSocket connection closed') 之後,message 的參數標成 MessageEvent<string>。這裡有一個作者沒處理的邊界:heartbeat() 只在收到第一個 ping 之後才會被呼叫,所以連線剛建立到第一個 ping 之間(最多一個 interval)client 沒有任何倒數在跑——如果 server 在這段時間就死了,client 永遠不會逾時。比較完整的做法是在 'open' 事件裡也呼叫一次 heartbeat(),讓看門狗從連線成功那一刻就開始計時。
12. 實測多連線與 server close 清理 18:43–21:59
npm run dev 沒錯誤,開三個 localhost:3000 分頁。terminal 看到 firing interval 與 pong。中途發現漏了一件事:wss.on('close') 要 clearInterval(interval),不然 server 關了 interval 還一直跑。重啟後逐一開連線:一條連線一個 pong,三條就三個 pong;互相送訊息正常;關掉一個分頁後 pong 數減一,全關後 firing interval 但沒 pong。若真有掉線會自動 close,可再接重連邏輯。

推理因為上一段完成了整個閉環,所以驗證的方式是看 terminal:每次 firing interval 後面跟幾個 pong。一開始先開一條連線,看到 firing interval 與一個 pong。中途他發現漏了一件事:wss.on('close') 要 clearInterval(interval),不然 server 關了 interval 還一直跑,補上後重啟。接著逐一開連線:一條一個 pong、兩條兩個、三條三個;互相送 test / test 2 都收得到;關掉一個分頁後 pong 減為兩個、再關剩一個、全關後只剩 firing interval 沒有 pong。結論:heartbeat 運作正常,若真有掉線會自動 close,可再接重連。
- P驗證指標:每次 firing interval 後的 pong 數 = 活著的連線數;開三個分頁看 1、2、3,關掉看遞減→ 練習
- R關分頁時瀏覽器會送 close 訊框,server 靠 'close' 事件移除 client,不是靠 heartbeat 踢掉→ 存+回想
AI 補充「pong 數 = 活著的連線數」是一個很好的驗證指標,因為它同時證明三件事:server 的 ping 有送到每條連線、client 的 isBinary 分流正確、client 回的 pong 通過 server 的 data[0] 檢查。截圖(1240 秒)就是這個瞬間:firing interval 後跟著三個 pong,上方程式碼可以看到剛補的 wss.on('close') 區塊。關於 clearInterval:作者補這步是因為 setInterval(見第 3 段)不會自己停,WebSocketServer 關閉後 wss.clients 是空的,interval 每輪空轉不會出錯,但會讓 Node.js 程序不肯結束、也算一種洩漏。要注意這個實測只驗證了「正常關閉」的路徑——關分頁時瀏覽器會送 close 訊框,server 是靠 'close' 事件移除 client,不是靠 heartbeat 踢掉。真正的死連線(拔網路線)在影片裡沒有示範,要驗證它得讓 client 不回 pong,例如在 DevTools 把網路切成 offline 後等兩個 interval。
13. 總結:間隔調大,開銷小但必要 22:00–23:03
作者收尾:範本設 5 秒,實際用 10、20 或 30 秒看用途;heartbeat 不太會拖累 server,overhead 很小,而且是必要的——另一個選項是因為爛客服系統永遠失去一個客戶。預告之後還有更多 WebSocket 影片會繼續強化這個 server 範本。
推理因為上一段已經證明機制能動,所以作者把注意力放回參數與取捨:範本設 5 秒是為了示範時看得到,實際用 10、20 或 30 秒看用途;heartbeat 每輪只是對每條連線送一個位元組,不太會拖累 server;而且它是必要的——另一個選項是回到第 1 段那個因為爛客服系統永遠失去一個客戶的情境。他預告之後還有更多 WebSocket 影片會繼續強化這個 server 範本。
AI 補充把整支影片的參數關係整理一下,方便自己調整:server 的 HEARTBEAT_INTERVAL 決定「多久發現死連線」(最壞兩個間隔),client 的 HEARTBEAT_TIMEOUT 必須大於 HEARTBEAT_INTERVAL 加上網路最差延遲,兩個常數目前分別寫在 server 與 client 檔案裡、沒有共用來源,改一邊要記得改另一邊。如果要進一步強化,常見的下一步有三個:連線 'open' 時就啟動 client 的看門狗(第 11 段提到的邊界)、把 server 的 ping 改成真正的位元組 Buffer.from([1])(第 3 段的小坑)、以及在 timeout 的重連註解處實作帶退避(exponential backoff)的自動重連。
4. 總結
作者先用客服系統說明死連線的代價,確立解法是定期 ping/pong。重構後在 server 定義 HEARTBEAT_INTERVAL 與 ping 函式(binary 送出),給每條連線掛 isAlive 並用 module augmentation 讓 TypeScript 接受;setInterval 每輪「不活就 terminate、否則設 false 再 ping」,收到 binary 且值相符的 pong 就設回 true,形成閉環。接著換到瀏覽器端:heartbeat() 每次收到 ping 就 clearTimeout 再重設一個比 server 間隔多 1 秒緩衝的 setTimeout,逾時就 close;用 Uint8Array 回 pong,用 Object.prototype.toString 判斷 Blob 來分流 ping 與一般訊息,接上 onmessage / onclose。實測三個分頁看到 pong 數隨連線數增減,補上 wss close 時 clearInterval,最後建議正式環境把間隔拉到 10–30 秒。
勘誤總整理
確定錯誤/已過時見仁見智(取決於版本或情境)
| 段落 | 原話(transcript 逐字) | 說明 |
|---|---|---|
| 3. 設計 heartbeat:間隔、值、ping 函式 5:09 |
「we're basically sending a one down and」 | ws 套件的 send() 收到數字時會先 data.toString(),所以實際送出的 binary 內容是字元 '1'(0x31),不是數值 1(0x01)。影片裡 client 端只判斷是否為 Blob、不比對值,所以機制照常運作;但「送一個 1 下去、期待收到一個 1 回來」這句在位元組層面不成立,client 若真的檢查 data[0] === 1 會失敗。 依據: ws v8.x websocket.js send():`if (typeof data === 'number') data = data.toString();` |
| 8. clearTimeout 與 WebSocketExt 型別 14:42 |
「actually see it is a node.js.timeout」 | 這段是跑在瀏覽器的前端程式,瀏覽器的 setTimeout 回傳 number;編輯器顯示 NodeJS.Timeout 是因為前端 tsconfig 同時載入了 @types/node,Node 的宣告蓋過 DOM 的。照抄 NodeJS.Timeout 在這個專案能過,但換到不含 @types/node 的前端專案會編譯失敗;可攜的寫法是 ReturnType<typeof setTimeout>。 依據: TypeScript lib.dom.d.ts:setTimeout(): number;@types/node globals.d.ts:setTimeout(): NodeJS.Timeout |
5. 推薦三個下一步
1. 往下挖深:WebSocket 協定內建的 ping/pong 控制訊框
影片用的是應用層自訂的 ping/pong;ws 套件其實有 ws.ping() / 'pong' 事件走協定層控制訊框,不會混進 message,也不用自己判斷 binary。比較兩者可以理解為什麼瀏覽器端沒有 API 能發協定層 ping。
YouTube 搜尋:ws library ping pong heartbeat nodejs WebSocket ping pong control frame RFC 6455 ws isAlive terminate example
2. 往旁邊對照:Socket.IO 內建的 heartbeat 與自動重連
影片最後留了「重連邏輯自己寫」的空位;Socket.IO 把 pingInterval / pingTimeout 與 exponential backoff 重連都做好了,對照它的設計能看出手寫版少了哪些邊界處理(例如 open 時就啟動看門狗)。
YouTube 搜尋:Socket.IO pingInterval pingTimeout explained websocket reconnect exponential backoff javascript socket.io vs ws heartbeat
3. 往上應用:正式環境的 WebSocket 連線管理與擴展
heartbeat 解決單一 server 上的死連線;上線後還要面對 load balancer 的 idle timeout、多台 server 之間的連線狀態、以及間隔秒數對數萬連線的成本,這些才決定 10 還是 30 秒。
YouTube 搜尋:scale websockets production load balancer idle timeout websocket connection management at scale nginx websocket proxy_read_timeout heartbeat
- 接著看 ←15 Fullstack Concepts Every Frontend Should Know · 那站分辨 polling/WebSocket/SSE,這站在 WebSocket 上動手寫 heartbeat
- 接著看 →How to scale WebSockets to millions of connections · heartbeat 解決單機的死連線,那站接著談多台 server 與 load balancer 怎麼擴
- 接著看 ←JavaScript Visualized - Event Loop, Web APIs, (Micro)task Queue · 先懂 setTimeout 與單執行緒排程,才看得出 interval 與 pong 回呼為何不會 race
- 相關 —Frontend System Design Explained w/ Senior Engineer (Microfrontends, Monorepo, MCP UI, Reactjs) · 共用 WebSocket、race condition:一站在系統設計層選型,一站在程式碼層保活
- 接著看 →WebSockets Aren’t as Reliable as You Think.. Here's Why · 那站專講 heartbeat;這站從 heartbeat 出發再加 SSE 備援、load balancer、broker