Tính năng chat chạy mượt trên máy dev, demo staging 50 user vẫn ổn. Lên production 3000 user — tin nhắn không đến được giữa hai server instance, connection rớt sau 60 giây, reconnect storm khi instance restart. “Real-time” hoá ra chỉ real-time khi chạy một process duy nhất.
Vấn đề không nằm ở WebSocket protocol mà ở mọi thứ xung quanh: scale stateful connection, phát hiện connection chết, broadcast xuyên server, load balancer cho kết nối lâu dài. Bài này đi từ nền tảng protocol đến những bài toán thực tế khi vận hành WebSocket ở quy mô production.
Từ polling đến WebSocket
Trước khi WebSocket tồn tại, web app muốn nhận data mới từ server phải tự “hỏi” liên tục. HTTP polling đơn giản nhất: client gửi GET request mỗi N giây, server trả data mới nếu có, trả rỗng nếu không. Với N = 5 giây và 10,000 user online, server nhận 2,000 request/giây chỉ để trả “không có gì mới” — lãng phí bandwidth và CPU cho cả hai phía.
Long polling cải thiện bằng cách server giữ request mở cho đến khi có data mới hoặc timeout (thường 30 giây). Client nhận response rồi gửi request mới ngay lập tức. Latency giảm đáng kể — message đến gần như tức thì vì server trả ngay khi có data. Nhưng mỗi “kết nối” vẫn tốn một HTTP request đang mở, mỗi message mới cần một request-response cycle kèm đầy đủ HTTP header. Với traffic cao, overhead header lặp đi lặp lại trở nên đáng kể.
Server-Sent Events (SSE) giải quyết hướng server-to-client rất gọn: một HTTP connection duy nhất giữ mở, server push event liên tục qua text/event-stream. Browser có sẵn EventSource API với auto-reconnect. SSE phù hợp tuyệt vời cho dashboard live, notification feed, stock ticker — bất kỳ use case nào chỉ cần server đẩy data xuống client. Nhưng SSE là one-way: client muốn gửi data lên vẫn phải dùng HTTP request riêng.
WebSocket giải quyết cả hai chiều. Sau handshake ban đầu qua HTTP, connection upgrade thành full-duplex — cả client và server gửi message bất kỳ lúc nào, không cần request-response, không có HTTP header overhead trên mỗi message. Đây là lựa chọn đúng khi cần giao tiếp hai chiều tần suất cao: chat, collaborative editing, multiplayer game, live trading.
Quy tắc chọn protocol: nếu chỉ cần server push data xuống client và tần suất không quá cao — dùng SSE, đơn giản hơn nhiều. Nếu cần hai chiều hoặc tần suất message rất cao — dùng WebSocket. Polling chỉ hợp lý khi interval dài (vài phút) và không cần real-time thực sự.
Handshake và protocol cơ bản
WebSocket bắt đầu bằng một HTTP request thông thường với header đặc biệt yêu cầu upgrade:
GET /ws/chat HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Server đồng ý upgrade thì trả 101 Switching Protocols. Từ thời điểm đó, TCP connection chuyển sang WebSocket protocol — không còn HTTP nữa. Điểm này quan trọng vì mọi middleware, proxy, load balancer trên đường đi phải hiểu và cho phép upgrade — nếu bất kỳ hop nào không hỗ trợ, connection fail.
Sau handshake, data truyền qua frame. Mỗi frame có opcode xác định loại: text frame (UTF-8), binary frame, ping, pong, close. Frame nhẹ hơn HTTP rất nhiều — overhead chỉ 2-14 bytes tuỳ kích thước payload, so với hàng trăm bytes HTTP header mỗi request. Đây là lý do WebSocket hiệu quả cho message nhỏ tần suất cao.
Vòng đời kết nối
Một WebSocket connection đi qua bốn giai đoạn: open, active (gửi nhận message), idle (heartbeat duy trì), và close.
Open và message
Khi connection mở, server thường cần xác thực và đăng ký user vào các channel phù hợp. Message gửi nhận là phần straightforward nhất — serialize data, gửi qua connection, deserialize phía nhận.
// Node.js với ws library
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws, req) => {
// Xác thực từ query param hoặc cookie
const token = new URL(req.url, "http://localhost").searchParams.get("token");
if (!verifyToken(token)) {
ws.close(4001, "unauthorized");
return;
}
ws.on("message", (data) => {
const msg = JSON.parse(data);
handleMessage(ws, msg);
});
ws.on("close", (code, reason) => {
cleanupConnection(ws);
});
});
Heartbeat — vì sao không thể bỏ qua
TCP connection có thể “chết” mà cả hai phía không biết — NAT gateway timeout sau vài phút idle, mobile user đi vào vùng mất sóng, hoặc process crash mà OS không kịp gửi FIN. Nếu không có heartbeat, server giữ connection “zombie” tiêu tốn memory và file descriptor mà không ai dùng. Với 10,000 connection, 5% zombie nghĩa là 500 connection lãng phí.
WebSocket protocol có sẵn ping/pong frame cho mục đích này. Server gửi ping định kỳ, client tự động trả pong (browser xử lý tự động, không cần code). Nếu sau N lần ping mà không nhận pong, server đóng connection.
const HEARTBEAT_INTERVAL = 30_000; // 30 giây
const HEARTBEAT_TIMEOUT = 10_000; // chờ pong 10 giây
wss.on("connection", (ws) => {
ws.isAlive = true;
ws.on("pong", () => { ws.isAlive = true; });
});
setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) {
ws.terminate(); // không nhận pong lần trước → đóng
continue;
}
ws.isAlive = false;
ws.ping();
}
}, HEARTBEAT_INTERVAL);
Interval 30 giây là con số phổ biến — đủ để phát hiện connection chết trong phút, không quá thường xuyên gây overhead. NAT gateway trên nhiều ISP timeout connection idle sau 60-120 giây, nên heartbeat cần ngắn hơn khoảng đó để giữ connection sống qua NAT.
Close và drain
Close frame cho phép đóng connection có trật tự — gửi close frame với status code, phía kia acknowledge bằng close frame ngược lại, rồi TCP đóng. Status code 1000 là đóng bình thường, 1001 là server shutting down, 4000-4999 dành cho application-specific.
Reconnect: exponential backoff với jitter
Connection sẽ rớt — đó là chắc chắn, không phải “nếu”. Network blip, server restart, load balancer rotate. Client phải tự reconnect, nhưng cách reconnect quyết định hệ thống sống hay chết dưới áp lực.
Reconnect ngay lập tức là sai lầm phổ biến nhất. Khi server restart, tất cả client mất connection cùng lúc. Nếu mọi client reconnect ngay, server vừa khởi động nhận hàng nghìn connection đồng thời — thundering herd. Server có thể không chịu nổi, crash lại, client reconnect lại, vòng lặp chết.
Exponential backoff giải quyết bằng cách tăng dần thời gian chờ giữa các lần thử: 1s, 2s, 4s, 8s… Thêm jitter (random offset) để các client không retry cùng thời điểm:
function reconnect(attempt = 0) {
const maxDelay = 30_000;
const base = Math.min(1000 * Math.pow(2, attempt), maxDelay);
const jitter = base * Math.random(); // 0 đến base
const delay = base / 2 + jitter; // base/2 đến base*1.5
setTimeout(() => {
const ws = new WebSocket(url);
ws.onopen = () => { /* reset attempt = 0, resubscribe channels */ };
ws.onclose = () => reconnect(attempt + 1);
}, delay);
}
Jitter là phần quan trọng — không có jitter, 5000 client đều chờ đúng 4 giây rồi retry cùng lúc. Có jitter, retry trải đều trong khoảng 2-6 giây, server nhận tải dần dần thay vì spike.
Scaling WebSocket: bài toán stateful
HTTP stateless nên scale dễ: thêm server, load balancer round-robin, xong. WebSocket stateful — mỗi connection gắn với một server instance cụ thể. User A kết nối server 1, user B kết nối server 2. Khi A gửi message cho B, server 1 nhận message nhưng connection của B nằm ở server 2. Đây là bài toán cốt lõi khi scale WebSocket.
Sticky session
Cách đơn giản nhất: load balancer route mọi request từ cùng client về cùng server (IP hash, cookie-based). WebSocket connection sau handshake đã gắn với một TCP connection cụ thể nên tự nhiên sticky. Vấn đề là HTTP upgrade request ban đầu phải đến đúng server — load balancer cần biết route dựa trên gì.
Nginx config cho WebSocket proxy:
upstream websocket_backend {
ip_hash; # sticky session theo IP
server ws1:8080;
server ws2:8080;
server ws3:8080;
}
server {
location /ws/ {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
# Quan trọng: timeout cho connection idle
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
proxy_read_timeout mặc định 60 giây — nếu không tăng, nginx đóng WebSocket connection sau 1 phút idle dù heartbeat đang chạy ở tầng application (heartbeat message không reset proxy timeout nếu dùng ping/pong frame thay vì text frame). Set 3600s hoặc cao hơn, để heartbeat ở tầng application quản lý connection lifecycle.
Pub/sub backbone
Sticky session giữ connection ổn định, nhưng không giải quyết bài toán broadcast. Khi user A trên server 1 gửi message vào room “general”, server 1 chỉ biết các connection của mình — không biết server 2 và 3 có ai trong room đó. Cần một kênh truyền message giữa các server instance.
Redis Pub/Sub là lựa chọn phổ biến nhất cho quy mô vừa. Khi server 1 nhận message cho room “general”, nó publish lên Redis channel room:general. Mọi server subscribe channel đó, nhận message, và forward cho các connection local thuộc room. Đơn giản, latency thấp (sub-millisecond trong cùng datacenter), setup nhanh.
// Go — publish message khi nhận từ WebSocket
func handleMessage(msg ChatMessage) {
payload, _ := json.Marshal(msg)
redisClient.Publish(ctx, "room:"+msg.Room, payload)
}
// Subscribe và forward cho local connections
func subscribeRoom(room string) {
sub := redisClient.Subscribe(ctx, "room:"+room)
ch := sub.Channel()
for msg := range ch {
var chat ChatMessage
json.Unmarshal([]byte(msg.Payload), &chat)
broadcastToLocalClients(room, chat)
}
}
Giới hạn của Redis Pub/Sub: message không persist — nếu server instance restart giữa chừng, message gửi trong lúc đó mất. Subscriber không nhận lại message cũ khi reconnect. Với chat app cần message history, lưu message vào database song song với pub/sub — pub/sub chỉ để broadcast real-time, database là source of truth.
NATS là alternative nhẹ hơn Redis cho pub/sub thuần, throughput cao hơn, protocol đơn giản. Kafka phù hợp khi cần message ordering mạnh và replay — nhưng latency cao hơn Redis/NATS, thường overkill cho chat real-time trừ khi có yêu cầu audit log hoặc event sourcing.
Giới hạn connection per server
Mỗi WebSocket connection tốn ít nhất một file descriptor ở OS level, một lượng memory cho buffer (thường 4-16 KB mỗi connection tuỳ library), và state trong application (user info, subscribed rooms). Trên Linux, file descriptor limit mặc định thường 1024 — cần tăng lên trước khi chạy production:
# /etc/security/limits.conf
websocket-app soft nofile 65536
websocket-app hard nofile 65536
# hoặc systemd service
[Service]
LimitNOFILE=65536
Với memory, ước tính thô: 50 KB/connection (buffer + application state) × 10,000 connection = 500 MB. Con số thực tế phụ thuộc vào message size, buffer policy, và runtime overhead. Go và Rust tiết kiệm memory hơn Node.js đáng kể cho workload nhiều connection — một server Go có thể giữ 100,000+ connection với vài GB RAM, Node.js thường cần nhiều hơn vì overhead V8 per-connection.
Benchmark trước khi deploy: tạo load test mô phỏng số connection dự kiến, đo memory và CPU thực tế, tính capacity per instance rồi plan số instance cần thiết.
Authentication
WebSocket handshake là HTTP request, nên có thể gửi cookie và header bình thường. Nhưng browser WebSocket API (new WebSocket(url)) không cho set custom header — đây là hạn chế quan trọng ảnh hưởng đến cách xác thực.
Ba cách phổ biến, mỗi cách có trade-off riêng.
Token trong query parameter: new WebSocket("wss://api.example.com/ws?token=eyJ..."). Đơn giản nhất, hoạt động với mọi client. Nhược điểm: token xuất hiện trong URL, có thể lọt vào access log, proxy log, và referer header. Giảm rủi ro bằng cách dùng token ngắn hạn (TTL vài phút, single-use) — client lấy token qua HTTP API trước, dùng token đó để connect WebSocket.
Cookie: nếu WebSocket server cùng domain với web app, browser tự gửi cookie trong handshake request. Không cần xử lý đặc biệt phía client. Nhược điểm: không hoạt động cross-origin trừ khi set SameSite=None; Secure, và mobile app hoặc non-browser client không có cookie.
First message authentication: connect trước, gửi message xác thực ngay sau open, server verify rồi mới cho phép gửi nhận message tiếp. Server cần timeout — nếu sau 5 giây không nhận auth message, đóng connection. Ưu điểm: linh hoạt, hoạt động với mọi client. Nhược điểm: connection đã tồn tại trước khi xác thực — tốn resource cho connection chưa xác thực, cần rate limit connection mới để tránh abuse.
Message format
JSON là lựa chọn mặc định vì dễ debug — mở DevTools, đọc message ngay. Nhưng JSON có overhead đáng kể: tên field lặp lại mỗi message, encoding text lớn hơn binary, parse chậm hơn.
Với ứng dụng tần suất thấp (chat, notification), JSON hoàn toàn đủ tốt — overhead không đáng kể so với simplicity. Với ứng dụng tần suất cao (game real-time 60 tick/s, live trading, IoT sensor stream), binary format tạo khác biệt rõ ràng.
Protocol Buffers (protobuf) hoặc MessagePack giảm message size 30-70% so với JSON và parse nhanh hơn. Trade-off là mất khả năng đọc trực tiếp trong DevTools — cần tool decode riêng. Mình thường bắt đầu với JSON, chuyển sang binary khi đo thấy message throughput là bottleneck thực tế, không phải premature optimization.
Dù chọn format nào, thiết kế message schema có field type (hoặc event) để phân loại:
{"type": "chat.message", "room": "general", "text": "hello", "ts": 1714100000}
{"type": "user.typing", "room": "general", "userId": "u42"}
{"type": "system.error", "code": 4002, "msg": "room not found"}
Field type cho phép router message phía nhận dispatch đến handler đúng mà không cần inspect toàn bộ payload.
Room và channel pattern
Hầu hết ứng dụng real-time không broadcast cho tất cả user — message thuộc về một “phòng” hoặc “kênh” cụ thể. Chat room, document collaboration session, game lobby đều là biến thể của pattern rooms/channels.
Server duy trì mapping room → set of connections. Khi message đến cho room, chỉ gửi cho connection trong room đó. Kết hợp với pub/sub backbone: mỗi room tương ứng một pub/sub channel.
type Hub struct {
mu sync.RWMutex
rooms map[string]map[*Connection]bool
}
func (h *Hub) Join(room string, conn *Connection) {
h.mu.Lock()
defer h.mu.Unlock()
if h.rooms[room] == nil {
h.rooms[room] = make(map[*Connection]bool)
go h.subscribeRedis(room) // subscribe Redis channel khi room có member đầu tiên
}
h.rooms[room][conn] = true
}
func (h *Hub) Broadcast(room string, msg []byte) {
h.mu.RLock()
defer h.mu.RUnlock()
for conn := range h.rooms[room] {
conn.Send(msg)
}
}
Khi connection đóng, remove khỏi tất cả room đã join. Khi room không còn member trên instance đó, unsubscribe Redis channel tương ứng — tránh nhận message cho room mà không ai cần.
Graceful server restart
Restart WebSocket server không giống restart HTTP server. HTTP request đang xử lý hoàn thành trong vài giây, request mới route sang instance khác — đơn giản. WebSocket connection có thể sống hàng giờ, và mỗi connection đang mở là state cần xử lý.
Connection draining là pattern chuẩn: server ngừng nhận connection mới (health check trả unhealthy để load balancer ngừng route), gửi close frame cho tất cả connection hiện có với code 1001 (going away), chờ grace period (10-30 giây) cho client reconnect sang instance khác, rồi mới shutdown process.
func gracefulShutdown(srv *http.Server, hub *Hub) {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
<-quit
log.Println("draining connections...")
// Ngừng nhận connection mới
srv.SetKeepAlivesEnabled(false)
// Gửi close frame cho tất cả connection
hub.CloseAll(websocket.CloseGoingAway, "server restarting")
// Chờ connection đóng hoặc timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)
}
Client nhận close code 1001 biết đây là server restart, reconnect ngay (với backoff ngắn hơn bình thường) thay vì báo lỗi cho user. Người dùng gần như không nhận ra server vừa restart nếu reconnect đủ nhanh.
Kubernetes cần chú ý: terminationGracePeriodSeconds phải đủ lớn cho drain period. Mặc định 30 giây thường đủ, nhưng nếu app cần gửi close frame cho hàng chục nghìn connection thì có thể cần lâu hơn.
Server-Sent Events khi WebSocket là overkill
Nhiều use case “real-time” thực chất chỉ cần server push — notification, live dashboard update, news feed, progress bar cho long-running task. Dùng WebSocket cho những trường hợp này là over-engineering: phải quản lý full-duplex protocol, heartbeat, reconnect phức tạp hơn cần thiết.
SSE đơn giản hơn đáng kể: một HTTP response giữ mở, server gửi event dạng text. Browser EventSource API tự reconnect khi connection rớt, tự gửi Last-Event-ID header để server biết gửi lại từ event nào. Không cần library đặc biệt, không cần load balancer config khác thường vì vẫn là HTTP.
// Server (Node.js)
app.get("/events", (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
const send = (event, data) => {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
send("connected", { ts: Date.now() });
const interval = setInterval(() => {
send("heartbeat", { ts: Date.now() });
}, 15_000);
req.on("close", () => clearInterval(interval));
});
// Client
const es = new EventSource("/events");
es.addEventListener("notification", (e) => {
showNotification(JSON.parse(e.data));
});
SSE qua HTTP/2 còn tốt hơn: multiplex trên cùng TCP connection, không tốn thêm connection riêng. Hạn chế duy nhất đáng kể: SSE chỉ truyền text (UTF-8), không hỗ trợ binary. Và giới hạn 6 connection per domain trên HTTP/1.1 (không áp dụng với HTTP/2).
Monitoring WebSocket
WebSocket connection sống lâu nên metric quan trọng khác với HTTP request-response.
Connection count là metric cơ bản nhất — gauge hiển thị số connection đang mở trên mỗi instance. Tăng đột biến có thể là reconnect storm. Giảm đột biến có thể là network issue hoặc server đang drain. Set alert khi tổng connection vượt capacity plan hoặc một instance nhận quá nhiều connection so với trung bình (load balancer không đều).
Message rate — counter đếm message gửi và nhận per second, chia theo loại (inbound/outbound, message type). Rate tăng bất thường có thể là client bug gửi message loop, hoặc broadcast storm khi room lớn nhận quá nhiều message.
Connection duration — histogram đo connection sống bao lâu. Nếu P50 duration rất ngắn (vài giây), client đang reconnect liên tục — dấu hiệu có vấn đề ở network, auth, hoặc server stability. Connection khoẻ mạnh nên sống hàng phút đến hàng giờ.
Error rate — đếm close frame với error code (không phải 1000/1001), handshake failure, authentication rejection. Error rate cao sau deploy là dấu hiệu code mới break WebSocket flow.
Prometheus metrics cho WebSocket server trông thường như thế này:
ws_connections_active{instance="ws-1"} 4521
ws_messages_total{direction="inbound",type="chat"} 892341
ws_messages_total{direction="outbound",type="chat"} 3412892
ws_connection_duration_seconds_bucket{le="60"} 234
ws_connection_duration_seconds_bucket{le="3600"} 8921
ws_handshake_errors_total{reason="auth_failed"} 47
Log mỗi connection open/close với metadata (user_id, room, duration, close_code) để debug khi cần. Không log nội dung message trên production — vừa tốn storage vừa có thể chứa PII.
Tóm tắt
WebSocket giải quyết bài toán giao tiếp hai chiều real-time mà HTTP không làm được hiệu quả. Nhưng “WebSocket hoạt động” và “WebSocket hoạt động ở quy mô lớn” là hai bài toán rất khác nhau.
Heartbeat ping/pong phát hiện connection chết và giữ NAT mapping — interval 30 giây, timeout sau vài lần không nhận pong. Client reconnect bắt buộc exponential backoff có jitter để tránh thundering herd khi server restart. Load balancer cần config proxy_read_timeout đủ lớn và hỗ trợ HTTP upgrade.
Scale horizontal qua pub/sub backbone — Redis Pub/Sub cho quy mô vừa, NATS hoặc Kafka cho yêu cầu cao hơn. Mỗi server instance chỉ broadcast cho connection local, pub/sub đảm bảo message đến mọi instance có subscriber. Room/channel pattern giữ broadcast có phạm vi, không flood toàn hệ thống.
Graceful restart bằng connection draining — ngừng nhận mới, gửi close 1001, chờ client reconnect sang instance khác. Authentication qua short-lived token trong query param hoặc first message, không phụ thuộc custom header mà browser API không hỗ trợ.
Và trước khi chọn WebSocket, tự hỏi: use case có thực sự cần hai chiều không? Nếu chỉ cần server push, SSE đơn giản hơn nhiều bậc — ít infrastructure overhead, auto-reconnect sẵn, hoạt động tốt qua HTTP/2. Chọn đúng tool cho đúng bài toán quan trọng hơn chọn tool “mạnh nhất”.