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

Connection State

socket เปล่า ๆ นั้นไร้ตัวตน server รู้ว่ามี client รายหนึ่ง เชื่อมต่ออยู่ แต่ไม่รู้ว่าพวกเขาเป็น ใคร หรือสนใจอะไร แอปพลิเคชันจริงต้องตอบคำถามอย่าง “นี่คือ user คนไหน?” และ “พวกเขา subscribe อะไรไว้บ้าง?” — และคำตอบต้องเดินทางไปกับ connection ตลอดอายุของตัวเอง นั่นคือ per-connection 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 ของตัวเอง

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”)

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 เต็มรูปแบบ

นี่คือกฎที่แยก 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"]
วงจรชีวิตของ per-connection state

ต่อ 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));

เดโมด้านล่างเก็บ per-connection state (user id ที่ได้มาตอน connect บวกกับ subscription set), mutate state บนข้อความ subscribe/unsubscribe, และพิมพ์บรรทัดรื้อถอนตอน close เปิดใน StackBlitz แล้วเชื่อมต่อ — บรรทัด client ที่ปรับแล้วระบุไว้ใน comment

Node.js

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 connectionFast access, ไม่ต้อง persist
User session dataRedis/databaseShare ข้าม server, persist reconnect
Message historyDatabaseAudit, replay capability
Presence (online/offline)Redis + WebSocket eventReal-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 เมื่อต้องการ
ทำไมต้องแก้ identity ของ connection หนึ่งครั้งตอน connect แทนที่จะแก้ในทุกข้อความ?
อันตรายของการ NOT clean up per-connection state ตอน close คืออะไร?
ทำไมต้องต่อ cleanup handler เข้ากับทั้ง `close` และ `error`?