ข้ามไปยังเนื้อหา

Heartbeats

ในบทเรียนที่แล้วเราบอกว่าการไถลเงียบ ๆ จาก live ไปสู่ dropped คือ transition ที่แอปลืม บทเรียนนี้อธิบายว่า ทำไมถึงเงียบ — และมอบเครื่องมือหนึ่งเดียวที่ทำลายความเงียบนั้น: heartbeat

นี่คือความจริงที่อึดอัดเกี่ยวกับ socket ที่เปิดอยู่: การไม่ทำอะไรเลยหน้าตาเหมือนการพังเป๊ะ ๆ ถ้าไม่มีฝ่ายไหนส่ง message เลยเป็นเวลาสามสิบวินาที คำอธิบายที่เป็นไปได้มีสองอย่าง และมองจากข้างในแล้วแยกไม่ออกเลย:

  • ทุกอย่างปกติดี และก็แค่ไม่มีใครมีอะไรจะพูด
  • Connection ตายไปเมื่อสิบวินาทีที่แล้ว — Wi-Fi handoff แลปท็อปที่หลับ load balancer ที่เก็บกวาด stream ที่ idle ทิ้ง — แล้ว packet ที่ประกาศการตายก็ไม่เคยมาถึง

TCP เองก็ช่วยตรงนี้ไม่ได้ TCP จะรู้ว่า connection พังก็ต่อเมื่อลองส่งแล้ว acknowledgement ไม่กลับมา ถ้าแอปของคุณ idle TCP ก็ idle ตาม จึงไม่เคยลองส่ง และไม่เคยรู้ ส่วน TCP keepalive ที่ติดมากับ operating system ค่าเริ่มต้นอาจใช้เวลาเป็น ชั่วโมง กว่าจะทำงาน — ไร้ประโยชน์สำหรับแอป real-time ผลลัพธ์คือ object WebSocket ของคุณรายงานอย่างมีความสุขว่า readyState === 1 (OPEN) บน connection ที่กลายเป็นซากมาหลายนาทีแล้ว

ทางรักษาเดียวคือ จงใจสร้าง traffic ตามช่วงเวลาที่กำหนด แล้วตรวจว่าคำตอบกลับมาไหม นั่นคือ heartbeat

มีกลไกที่แตกต่างกันสองอย่าง และผู้คนสับสนระหว่างสองอย่างนี้อยู่เสมอ

  • Protocol ping/pong frame WebSocket protocol นิยาม control frame พิเศษไว้: ping frame และ pong frame ตาม spec แล้ว endpoint ที่รับ ping ต้องตอบด้วย pong เสมอ frame พวกนี้อยู่ต่ำกว่าระดับ message handler ของคุณ — เป็นส่วนหนึ่งของ framing layer ที่พูดถึงในโมดูล foundations
  • Application-level heartbeat ข้อความธรรมดาที่codeของคุณส่งและตอบ เช่น JSON {"type":"ping"} ที่ server ของคุณตอบกลับด้วย {"type":"pong"}

ทำไมถึงต้องสร้างเวอร์ชัน app-level ในเมื่อ protocol ให้ ping/pong มาฟรี ๆ อยู่แล้ว? เพราะ เบราว์เซอร์ไม่อนุญาตให้คุณใช้ protocol frame เหล่านั้น WebSocket API ของเบราว์เซอร์ไม่มี method ping() และไม่ยิง event ใดเลยเมื่อ ping หรือ pong มาถึง — ทุกอย่างถูกจัดการอย่างมองไม่เห็นอยู่ภายในเบราว์เซอร์ ฝั่ง server ส่ง protocol ping ไปยังเบราว์เซอร์ได้ แต่ code ฝั่ง client ในเบราว์เซอร์ทั้งส่งและสังเกตไม่ได้เลย ดังนั้นในทางปฏิบัติ:

  • Server ไปยังเบราว์เซอร์: server อาจใช้ protocol ping/pong จริงเพื่อตรวจสอบเบราว์เซอร์
  • เบราว์เซอร์ไปยัง server: client ต้องใช้ message ระดับ app เพราะเห็นได้แค่นั้น

ดังนั้นระบบ production ส่วนใหญ่จึงรัน heartbeat แบบ app-level เพื่อให้ logic เดียวกันทำงานได้บนทุกแพลตฟอร์ม นั่นคือสิ่งที่เราจะสร้างด้านล่าง

รูปแบบของ app-level heartbeat มักจะเหมือนกันเสมอ:

sequenceDiagram
  participant C as Client
  participant S as Server
  loop every interval
    C->>S: ping
    S-->>C: pong
    Note over C: pong seen — mark connection healthy
  end
ลูปของ app-level heartbeat

ใน code ก็แค่ interval timer บวก state ชิ้นเล็ก ๆ: ส่ง ping ทุก N วินาที แล้วจำว่า pong ที่คู่กันกลับมาหรือยัง นี่คือหัวใจของตัวเองในรูป TypeScript ธรรมดา:

const HEARTBEAT_MS = 5000;
function startHeartbeat(ws: WebSocket) {
const timer = setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'ping', t: Date.now() }));
}, HEARTBEAT_MS);
ws.addEventListener('message', (event) => {
const msg = JSON.parse(event.data as string);
if (msg.type === 'pong') {
// The connection answered — it is genuinely alive right now.
}
});
ws.addEventListener('close', () => clearInterval(timer));
}

การส่ง ping คือครึ่งที่ง่าย ครึ่งที่มีค่า — การสังเกตว่า pong ไม่เคยมา — คือบทเรียนถัดไปทั้งบท สำหรับตอนนี้ ให้โฟกัสที่จังหวะ: ชีพจรที่สม่ำเสมอ และคำตอบสำหรับทุกครั้งที่เต้น

เดโมด้านล่างใช้ socket ในหน้าที่มี WebSocket API จริง mock เล่นบทเป็น server ที่ประพฤติดี: ทุกครั้งที่รับ message ping ระดับ app ก็จะตอบ pong กลับ ส่วน client ส่ง ping ทุกหนึ่งวินาทีแล้ว log การไปกลับ ลองดู heartbeat เข้าจังหวะของตัวเอง

JavaScript

คุณจะเห็น ping ออกไปและ pong กลับมา หนึ่งคู่ต่อวินาที ทุก pong ที่กลับมาคือหลักฐานชัดเจนว่า connection ยังมีชีวิต ในขณะนั้น — ไม่ใช่เมื่อสามสิบวินาทีที่แล้ว ไม่ใช่ “น่าจะ” นั่นคือจุดประสงค์ทั้งหมดของ heartbeat: เปลี่ยนความเงียบซึ่งไม่บอกอะไรเลย ให้เป็นสัญญาณสม่ำเสมอที่เชื่อถือได้จริง

Heartbeat Typeทำงานอย่างไรข้อดี
Protocol-level Ping/PongWebSocket frame opcode 0x9/0xAbrowser handle อัตโนมัติ, lightweight
Application-level Pingส่ง JSON { "type": "ping" }flexible, บน binary หรือ text frame
Keepalive TCPOS-level TCP keepaliveไม่ต้องใช้ application code

ไม่มี Heartbeat เลย อาการ:

  • connection ดูเหมือนเปิดอยู่ใน server แต่ client จริงหายไป (zombie connection)
  • load balancer หรือ firewall ปิด idle connection โดยไม่แจ้ง
  • implement server-side ping ทุก 30 วินาที, ถ้าไม่มี pong → ปิด connection

Heartbeat Interval สั้นเกิน อาการ:

  • ping ทุก 1 วินาท — server และ client เสียเวลา ping/pong แทนที่จะส่งข้อมูล
  • battery drain บน mobile device
  • interval 30-60 วินาทีเหมาะสำหรับส่วนใหญ่ — ขึ้นกับ firewall timeout

💡 ตัวอย่างจากของจริง

Discord Gateway:

  • ส่ง { "op": 1, "d": null } (Heartbeat opcode) ตาม interval ที่ server กำหนด
  • server ตอบ { "op": 11 } (Heartbeat ACK) — client รู้ว่า connection alive

Slack RTM API:

  • ping/pong ทุก 30 วินาที
  • ถ้าไม่มี pong ใน 10 วินาที → reconnect อัตโนมัติ
ทำไม WebSocket ที่ idle ถึงดูเหมือนยังมีชีวิตทั้งที่จริง ๆ แล้วตายไปแล้ว?
ทำไม client ที่เป็นเบราว์เซอร์มักใช้ app-level heartbeat แทน protocol ping/pong frame?
ในเดโม แต่ละ pong ที่กลับมาพิสูจน์อะไร?