Connection State
socket เปล่า ๆ นั้นไร้ตัวตน server รู้ว่ามี client รายหนึ่ง เชื่อมต่ออยู่ แต่ไม่รู้ว่าพวกเขาเป็น ใคร หรือสนใจอะไร แอปพลิเคชันจริงต้องตอบคำถามอย่าง “นี่คือ user คนไหน?” และ “พวกเขา subscribe อะไรไว้บ้าง?” — และคำตอบต้องเดินทางไปกับ connection ตลอดอายุของตัวเอง นั่นคือ per-connection state
ทำไม state ต้องอยู่ที่ไหนสักแห่ง
หัวข้อที่มีชื่อว่า “ทำไม state ต้องอยู่ที่ไหนสักแห่ง”ใน HTTP คุณสร้าง context ขึ้นใหม่ในทุก request จาก token หรือ session cookie เพราะแต่ละ request เป็นอิสระต่อกัน WebSocket ตรงกันข้าม: หนึ่ง connection ที่มีชีวิตยืนยาว ดังนั้นคุณจึงอยากแก้คำถาม “นี่ใคร” หนึ่งครั้ง ตอน connect แล้วเก็บไว้ สิ่งทั่วไปที่คุณแนบเข้าไป:
- Identity — user id ที่แก้มาจาก token บน upgrade request
- Subscriptions — room, channel, หรือ topic ใดที่ socket นี้ต้องการ
- Bookkeeping — เวลาที่พวกเขาเชื่อมต่อ, ครั้งล่าสุดที่คุณได้ยินจากพวกเขา (สำหรับ heartbeat), counter ต่อ socket
ที่ตามธรรมชาติสำหรับวางทั้งหมดนี้คือ บนตัว socket object เอง เพื่อว่าที่ไหนก็ตามที่คุณมี ws คุณก็มี context ของตัวเอง
การแนบ state ให้ socket
หัวข้อที่มีชื่อว่า “การแนบ state ให้ socket”pattern ที่ง่ายที่สุดคือแขวน object ธรรมดาไว้บน socket เมื่อ connection เปิด อ่าน identity จาก upgrade req แล้วเก็บอะไรก็ตามที่คุณต้องการ:
wss.on('connection', (ws, req) => { // Resolve identity once, from the upgrade request (token, cookie, query…). const userId = resolveUser(req); // your auth logic
// Attach a state bag to this specific socket. ws.state = { userId, subscriptions: new Set(), connectedAt: Date.now(), };
ws.send(`hello ${userId}`);});ตอนนี้ handler ใดก็ตามที่มี socket สามารถอ่าน ws.state.userId หรือตรวจ ws.state.subscriptions ได้ ทางเลือกที่สะอาดและ type-safe กว่าในแอปขนาดใหญ่คือ side-table — Map จาก socket ไปยัง state — ซึ่งหลีกเลี่ยงการ monkey-patch object ของไลบรารี:
const stateBySocket = new Map(); // ws -> { userId, subscriptions, ... }
wss.on('connection', (ws, req) => { stateBySocket.set(ws, { userId: resolveUser(req), subscriptions: new Set(), });});ทั้งสองวิธีใช้ได้ การแนบโดยตรงสะดวก; ส่วน side-table ทำให้ตัว socket object สะอาดและให้เหตุผลได้ง่ายเมื่อคุณต้องการ reverse lookup ด้วย (เช่น “หา socket ของ user 42”)
การ mutate state เมื่อข้อความเข้ามา
หัวข้อที่มีชื่อว่า “การ mutate state เมื่อข้อความเข้ามา”state ไม่ได้ถูกแช่แข็งไว้ตอน connect — แต่เปลี่ยนไปเรื่อย ๆ ข้อความ subscribe ควรเพิ่มเข้า set; ข้อความ unsubscribe ควรลบออกจาก set:
ws.on('message', (data) => { const msg = JSON.parse(data.toString()); if (msg.type === 'subscribe') { ws.state.subscriptions.add(msg.channel); } else if (msg.type === 'unsubscribe') { ws.state.subscriptions.delete(msg.channel); }});set subscriptions ต่อ socket นี้คือเมล็ดพันธุ์ของ room ซึ่งบทเรียนถัดไปจะเพาะให้เติบโตเป็นระบบ broadcast เต็มรูปแบบ
Clean up ตอน close — เสมอ
หัวข้อที่มีชื่อว่า “Clean up ตอน close — เสมอ”นี่คือกฎที่แยก server ที่แข็งแรงออกจาก server ที่รั่ว: ทุก state ที่คุณสร้างตอน connect คุณต้องรื้อทิ้งตอน close socket ที่ disconnect แต่ทิ้ง entry ของตัวเองไว้ใน Map หรือทิ้ง id ของตัวเองไว้ใน room set คือ memory leak และ bug ด้านความถูกต้อง — คุณอาจ broadcast ไปหา ghost
flowchart LR CONN["connection<br/>(ws, req)"] --> CREATE["create state<br/>userId, subscriptions"] CREATE --> LIVE["live: read & mutate<br/>on each message"] LIVE --> CLOSE["close / error"] CLOSE --> CLEAN["delete state<br/>remove from rooms"] CLEAN --> GC["socket eligible<br/>for garbage collection"]
ต่อ cleanup เข้ากับ ทั้ง close และ error เพราะ error มักจะมาก่อนการ disconnect แบบเงียบ ๆ:
function cleanup(ws) { stateBySocket.delete(ws); // also remove ws from any room sets it joined}
ws.on('close', () => cleanup(ws));ws.on('error', () => cleanup(ws));ดู state ทำงานจริง
หัวข้อที่มีชื่อว่า “ดู state ทำงานจริง”เดโมด้านล่างเก็บ per-connection state (user id ที่ได้มาตอน connect บวกกับ subscription set), mutate state บนข้อความ subscribe/unsubscribe, และพิมพ์บรรทัดรื้อถอนตอน close เปิดใน StackBlitz แล้วเชื่อมต่อ — บรรทัด client ที่ปรับแล้วระบุไว้ใน comment
Needs the Node.js runtime — open in StackBlitz to run.
ทุกการตอบกลับ echo รายการ subscription ปัจจุบันของ socket — state ที่คงอยู่ข้ามข้อความและเป็นของ connection หนึ่งนั้น เมื่อ client disconnect บรรทัด cleanup พิสูจน์ว่าการรื้อถอนได้รันแล้ว รักษาวินัยนั้นไว้แล้ว memory ของ server ของคุณจะถูกจำกัดขอบเขตไม่ว่าจะมี client เข้าออกมากแค่ไหน
| State Type | เก็บที่ไหน | เหมาะกับ |
|---|---|---|
| Connection metadata (userId, rooms) | In-memory per connection | Fast access, ไม่ต้อง persist |
| User session data | Redis/database | Share ข้าม server, persist reconnect |
| Message history | Database | Audit, replay capability |
| Presence (online/offline) | Redis + WebSocket event | Real-time, expire อัตโนมัติ |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”เก็บ State มากเกินในทุก Connection อาการ:
- เก็บ full user profile, permission list, history ใน connection object
- 10,000 connection = 10,000 copy ของข้อมูลเดียวกัน
- เก็บแค่ identifier ใน connection, ดึงข้อมูลจาก shared store เมื่อต้องการ
State ที่ไม่ Cleanup เมื่อ Disconnect อาการ:
- connection disconnect แต่ state ยังอยู่ใน memory map
- memory leak — server ช้าลงตามเวลา
- implement
ws.on('close', () => { connections.delete(userId) })เสมอ
💡 ตัวอย่างจากของจริง
Discord:
- เก็บ userId, guildIds, channel subscriptions ต่อ WebSocket connection
- session state ใน Redis — reconnect กลับ session เดิมได้
Slack:
- connection state minimal — user identity และ workspace
- ข้อมูลหนักอยู่ใน database, query เมื่อต้องการ