Phân tích toàn bộ WebSocket protocol từ bit-level framing đến production patterns — dành cho bạn muốn hiểu tại sao chứ không chỉ biết cách dùng. Tài liệu offline, không cần internet. RFC 6455.
Toàn bộ vòng đời WebSocket — 5 phase (click vào từng phase)
WebSocket là giao thức truyền thông full-duplex (hai chiều đồng thời) chạy trên một TCP connection duy nhất. Được chuẩn hóa trong RFC 6455 (2011), WebSocket giải quyết hạn chế cơ bản của HTTP: client luôn phải là người khởi tạo request.
Vị trí trong mạng
WebSocket vs HTTP vs TCP thuần
| HTTP/1.1 | WebSocket | Raw TCP | |
|---|---|---|---|
| Connection | Stateless, request-response | Persistent, stateful | Persistent, stateful |
| Direction | Client→Server only | Full-duplex | Full-duplex |
| Overhead/msg | 700–1500 bytes headers | 2–14 bytes frame header | 0 bytes (raw) |
| Browser support | Native | Native (2012+) | Không (vì bảo mật) |
| Proxies/CDN | Transparent | Cần cấu hình | Thường bị block |
| Binary data | Base64 encode | Native binary frames | Native |
Đặc điểm nổi bật
- Full-duplex: Client và server đều có thể gửi message bất kỳ lúc nào, độc lập nhau.
- Low latency: Không có round-trip overhead của HTTP headers — frame header chỉ 2–14 bytes.
- Persistent connection: Một kết nối TCP dùng cho toàn bộ session — không tốn chi phí TCP handshake lặp lại.
- Binary-native: Gửi nhận ArrayBuffer trực tiếp, không cần encode sang Base64.
- Event-driven: Server push data ngay lập tức khi có event, không cần client hỏi.
Use cases phù hợp
Real-time bidirectional
- Chat, messaging apps
- Multiplayer online games
- Collaborative editing (Google Docs)
- Live code editors (CodeSandbox)
- Video call signaling (WebRTC)
Server push / live feed
- Financial tickers, trading platforms
- Live sports scores
- IoT sensor telemetry
- Log streaming, monitoring dashboards
- CI/CD build progress
WebSocket bắt đầu bằng một HTTP/1.1 request thông thường, sau đó "nâng cấp" (upgrade) lên WebSocket protocol. Sau khi upgrade thành công, HTTP không còn được dùng nữa — toàn bộ traffic chuyển sang WebSocket framing.
Client Request
GET /ws HTTP/1.1 Host: example.com Upgrade: websocket ← bắt buộc Connection: Upgrade ← bắt buộc Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== ← 16 bytes ngẫu nhiên, base64 Sec-WebSocket-Version: 13 ← luôn là 13 (RFC 6455) Sec-WebSocket-Extensions: permessage-deflate ← tùy chọn: yêu cầu compression Sec-WebSocket-Protocol: chat, superchat ← tùy chọn: subprotocol
Server Response — 101 Switching Protocols
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= ← xác minh server đọc key Sec-WebSocket-Protocol: chat ← server chọn 1 subprotocol Sec-WebSocket-Extensions: permessage-deflate ← nếu accept compression
Tính toán Sec-WebSocket-Accept
Server tính giá trị này để chứng minh nó thực sự đọc WebSocket upgrade request (không phải proxy cache trả lời). Quy trình cố định trong RFC 6455:
1. Lấy Sec-WebSocket-Key: "dGhlIHNhbXBsZSBub25jZQ=="
2. Nối với Magic GUID: "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
3. Concatenated string: "dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
4. SHA-1 hash (hex): b37a4f2cc0624f1690f64606cf385945b2bec4ea
5. Base64 encode: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= ← đây là Accept value
Node.js:
const key = req.headers['sec-websocket-key'];
const accept = require('crypto')
.createHash('sha1')
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
.digest('base64');
258EAFA5-E914-47DA-95CA-C5AB0DC85B11 là chuỗi cố định được chọn ngẫu nhiên khi viết RFC 6455. Nó không có ý nghĩa gì đặc biệt — chỉ để đảm bảo server biết nó đang nói WebSocket, không phải HTTP thông thường.
Sequence diagram
Toàn bộ quá trình từ TCP connect đến WebSocket frame đầu tiên
Các trường hợp đặc biệt
- Server từ chối upgrade: Trả về HTTP 400/403/404 — connection đóng lại, không upgrade.
- Proxy không hiểu WebSocket: Một số HTTP proxy cũ không forward
Upgradeheader → dùng wss:// để traffic đi qua TLS tunnel (proxy treat như opaque CONNECT). - HTTP/2 với WebSocket: RFC 8441 (2018) — WebSocket over HTTP/2. Dùng CONNECT method với
:protocol: websocket. Ít phổ biến hơn, nhưng giúp multiplex nhiều WS connections trên 1 HTTP/2 connection.
Đây là core của WebSocket protocol. Mọi message đều được đóng gói trong một hoặc nhiều frames. Hiểu frame structure = hiểu WebSocket hoạt động như thế nào ở bit level.
RFC 6455 Frame Layout
Cấu trúc frame WebSocket — mỗi ô đại diện cho một field (không đúng tỷ lệ bit)
Opcode Table
| Opcode | Tên | Loại | Mô tả |
|---|---|---|---|
| 0x0 | Continuation | Data | Frame tiếp theo của fragmented message |
| 0x1 | Text | Data | UTF-8 encoded text payload |
| 0x2 | Binary | Data | Raw binary payload (ArrayBuffer) |
| 0x3–7 | — | Reserved | Dành cho extension tương lai (non-control) |
| 0x8 | Close | Control | Khởi tạo close handshake |
| 0x9 | Ping | Control | Keepalive probe — peer PHẢI reply Pong |
| 0xA | Pong | Control | Reply cho Ping, echo cùng payload |
| 0xB–F | — | Reserved | Dành cho extension tương lai (control) |
MASK bit — Tại sao Client phải mask?
RFC 6455 bắt buộc: Client → Server phải mask (MASK=1). Server → Client KHÔNG được mask (MASK=0). Vi phạm → Protocol error → đóng connection.
Lý do: ngăn chặn cache poisoning attacks qua HTTP proxy. Một số proxy cũ cache HTTP response dựa vào pattern trong body. Nếu attacker có thể kiểm soát payload WS không mask, họ có thể craft binary data trông giống như HTTP response → proxy cache nó → các request HTTP sau bị nhiễm. Mask XOR làm cho payload không predictable với proxy.
Masking Algorithm
// Unmasking (và masking — cùng phép tính)
for (let i = 0; i < payload.length; i++) {
unmasked[i] = masked[i] ^ maskingKey[i % 4];
}
// Ví dụ thực tế:
// Masking key (4 bytes): 0x37 0xFA 0x21 0x3B
// Original: H e l l o
// 0x48 0x65 0x6C 0x6C 0x6F
// Key cycle: [k0] [k1] [k2] [k3] [k0]
// XOR: 0x37 0xFA 0x21 0x3B 0x37
// Masked: 0x7F 0x9F 0x4D 0x57 0x58 (gửi lên wire)
// Receiver XOR lại với cùng key → ra "Hello"
Payload Length Encoding
| Giá trị 7-bit | Ý nghĩa | Tổng bytes header |
|---|---|---|
| 0–125 | Payload length chính là con số này | 2 bytes (+ 4 nếu masked) |
| 126 | Đọc thêm 2 bytes tiếp theo (uint16 big-endian) → max 65,535 bytes | 4 bytes (+ 4 nếu masked) |
| 127 | Đọc thêm 8 bytes tiếp theo (uint64 big-endian) → max ~9.2 EB | 10 bytes (+ 4 nếu masked) |
Fragmentation (Message Splitting)
Message lớn có thể chia thành nhiều frames. FIN bit xác định frame cuối:
Frame 1: FIN=0, opcode=0x1 (Text), payload="Hello, " ← first fragment Frame 2: FIN=0, opcode=0x0 (Continuation), payload="World" Frame 3: FIN=1, opcode=0x0 (Continuation), payload="!" ← final fragment → Receiver reassemble: "Hello, World!" Lưu ý: Control frames (Ping/Pong/Close) không được fragmented và CÓ THỂ chen vào giữa fragmented data frames.
Control frames quản lý lifecycle của WebSocket connection — không mang application data. Ba loại: 0x8 Close 0x9 Ping 0xA Pong.
Ping / Pong — Keepalive
Dùng để detect stale connections (xem chi tiết tại Section 10).
// Server gửi Ping mỗi 30s
ws.ping(Buffer.from('heartbeat'));
// Client tự động reply Pong (browser tự xử lý)
// Node.js ws library:
ws.on('ping', (data) => {
console.log('Received ping:', data.toString());
// ws library tự gửi Pong, không cần gọi ws.pong() thủ công
});
ws.on('pong', (data) => {
ws.isAlive = true; // mark connection alive
});
Close Frame — Đóng kết nối đúng cách
Close handshake là graceful: cả hai bên exchange Close frames trước khi TCP đóng. Payload của Close frame có thể chứa status code (2 bytes, uint16 big-endian) và reason string (UTF-8).
Close Status Codes
| Code | Tên | Ý nghĩa |
|---|---|---|
1000 | Normal Closure | Đóng bình thường, tác vụ hoàn thành |
1001 | Going Away | Server restart / browser tab đóng / navigate away |
1002 | Protocol Error | Vi phạm WebSocket protocol |
1003 | Unsupported Data | Nhận data type không hỗ trợ (ví dụ: binary khi chỉ expect text) |
1005 | No Status (reserved) | Không được gửi trong Close frame — chỉ dùng internally |
1006 | Abnormal (reserved) | Không được gửi — dành cho khi connection drop không có Close frame |
1007 | Invalid Frame Data | Text frame chứa non-UTF-8 data |
1008 | Policy Violation | Message vi phạm chính sách ứng dụng |
1009 | Message Too Big | Message vượt quá kích thước cho phép |
1011 | Internal Error | Server gặp unexpected condition |
4000–4999 | Application | Dành cho application-defined codes (không chuẩn hóa) |
Close Handshake Flow
Initiator (ví dụ: client) Receiver (server) │ │ │── Close(1000, "done") ──────────────→│ ← gửi Close frame │ │ ← server dừng nhận data, flush buffer │←── Close(1000, "ok") ───────────────│ ← echo Close frame │ │ │── TCP FIN ─────────────────────────→│ ← initiator đóng TCP │←── TCP FIN ──────────────────────── │ ← receiver đóng TCP // JavaScript: ws.close(1000, 'Session ended normally'); ws.onclose = e => console.log(e.code, e.reason); // 1000 "Session ended normally"
Extensions modify behavior của WebSocket ở frame level. Extension phổ biến nhất là permessage-deflate (RFC 7692) — nén payload bằng DEFLATE algorithm (LZ77 + Huffman), y hệt gzip nhưng per-message.
Negotiation trong Handshake
// Client offer (HTTP request header): Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits // Server accept (HTTP response header): Sec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover // Sau đó: frames được nén có RSV1 = 1 // Browser WebSocket API tự handle — không cần code gì thêm
Tham số quan trọng
| Parameter | Default | Ý nghĩa |
|---|---|---|
client_max_window_bits | 15 | LZ77 sliding window size (8–15). Lớn hơn = nén tốt hơn, tốn RAM hơn |
server_max_window_bits | 15 | Window size phía server |
client_no_context_takeover | off | Reset LZ77 context sau mỗi message (ít RAM, nén kém hơn) |
server_no_context_takeover | off | Tương tự phía server |
Context Takeover vs No-Context
With Context Takeover (default)
LZ77 sliding window tích lũy qua các messages. Message sau có thể reference pattern từ message trước → compression ratio tốt hơn (~60–80% reduction với JSON).
Nhược điểm: Server cần duy trì LZ77 context per-connection (~32KB RAM mỗi connection × 100K connections = 3.2GB).
No-Context Takeover
Fresh LZ77 state cho mỗi message. Mỗi message nén độc lập. Compression ratio thấp hơn với small messages.
Ưu điểm: RAM per-connection thấp hơn, dễ scale horizontal hơn (không cần stick session cho context).
RSV bits và Extensions
RSV1, RSV2, RSV3 trong frame header được reserved cho extensions. permessage-deflate dùng RSV1: nếu RSV1=1 → frame đó được compressed. Nếu không negotiate extension mà nhận frame có RSV≠0 → Protocol error → đóng connection.
Subprotocol là application-level protocol chạy trên WebSocket — định nghĩa message format, semantics, và conventions. WebSocket spec không quan tâm bạn gửi gì; subprotocol là thỏa thuận giữa client và server về ý nghĩa của data.
Negotiation
// Client đề nghị (theo thứ tự ưu tiên):
new WebSocket('wss://example.com/ws', ['chat-v2', 'chat-v1']);
// → Sec-WebSocket-Protocol: chat-v2, chat-v1
// Server chọn 1 (hoặc không chọn):
// → Sec-WebSocket-Protocol: chat-v2
// Sau khi connect:
console.log(ws.protocol); // "chat-v2"
Subprotocols phổ biến
| Subprotocol | Spec | Use case |
|---|---|---|
STOMP | stomp.github.io | Message broker (ActiveMQ, RabbitMQ) — publish/subscribe qua WS |
MQTT | RFC 7252 + MQTT over WS | IoT lightweight messaging |
WAMP | wamp.ws | RPC + Pub/Sub trong 1 protocol |
graphql-ws | github.com/enisdenjo | GraphQL subscriptions |
json-rpc | jsonrpc.org | Remote procedure calls over WS |
ocpp2.0.1 | openchargealliance.org | EV charging station protocol |
Thiết kế Custom Subprotocol
// Ví dụ: custom chat subprotocol "mychat-v1"
// Mọi message là JSON với trường "type" bắt buộc:
{ "type": "message", "text": "hello", "room": "general" }
{ "type": "typing", "isTyping": true, "room": "general" }
{ "type": "join", "name": "Alice", "room": "general" }
{ "type": "presence", "users": ["Alice"], "room": "general" } // server→client
{ "type": "error", "code": 4001, "msg": "Room full" } // server→client
// Best practices:
// 1. Version trong tên: "mychat-v1", "mychat-v2"
// 2. Luôn có "type" field để dispatch
// 3. Định nghĩa error codes (4000–4999)
// 4. Document schema (JSON Schema, OpenAPI, Protobuf IDL)
Constructor và Properties
// Tạo connection
const ws = new WebSocket('wss://example.com/ws');
const ws = new WebSocket('wss://example.com/ws', 'chat'); // 1 subprotocol
const ws = new WebSocket('wss://example.com/ws', ['chat', 'raw']); // nhiều subprotocols
// Properties (read-only sau khi connect)
ws.url // URL đã connect
ws.protocol // subprotocol được negotiate (hoặc "")
ws.extensions // extensions được negotiate
ws.readyState // 0=CONNECTING · 1=OPEN · 2=CLOSING · 3=CLOSED
ws.bufferedAmount // bytes đã queue để gửi nhưng chưa flush (backpressure indicator)
// Configurable
ws.binaryType = 'arraybuffer'; // hoặc 'blob' (default)
readyState State Machine
Chuyển đổi trạng thái — các đường dashed là error paths
Event Handlers
ws.onopen = (event) => {
console.log('Connected!');
ws.send('Hello Server!');
};
ws.onmessage = (event) => {
// event.data: string (text frame) | ArrayBuffer | Blob (binary frame)
if (typeof event.data === 'string') {
const msg = JSON.parse(event.data);
} else {
// Binary data
const view = new DataView(event.data);
const value = view.getFloat32(0, true);
}
};
ws.onerror = (event) => {
// event là generic Event — không có error message chi tiết vì bảo mật
// (không muốn cross-origin script biết lý do thất bại)
console.error('WebSocket error. Check network tab.');
};
ws.onclose = (event) => {
console.log(`Closed: code=${event.code} reason="${event.reason}" clean=${event.wasClean}`);
// event.wasClean = true nếu close handshake hoàn chỉnh (không phải network drop)
};
send() và bufferedAmount — Backpressure
// Gửi text
ws.send('Hello');
ws.send(JSON.stringify({ type: 'message', text: 'Hi' }));
// Gửi binary
ws.send(new ArrayBuffer(1024));
ws.send(new Uint8Array([1, 2, 3, 4]));
ws.send(new Blob([data]));
// Backpressure: tránh fill TCP buffer
function sendWithBackpressure(data) {
if (ws.readyState !== WebSocket.OPEN) return;
if (ws.bufferedAmount > 64 * 1024) { // 64KB threshold
requestAnimationFrame(() => sendWithBackpressure(data)); // retry next frame
return;
}
ws.send(data);
}
Setup cơ bản
import { createServer } from 'http';
import { WebSocketServer, WebSocket } from 'ws';
import { readFileSync } from 'fs';
const server = createServer((req, res) => {
// Serve static files
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(readFileSync('./index.html'));
});
const wss = new WebSocketServer({ server });
wss.on('connection', (ws, req) => {
console.log('Client connected from', req.socket.remoteAddress);
ws.on('message', data => handleMessage(ws, data));
ws.on('close', (code, reason) => handleClose(ws, code, reason));
ws.on('error', err => console.error('WS error:', err.message));
});
server.listen(3000, () => console.log('Server running on :3000'));
Broadcasting
function broadcast(message, excludeWs = null) {
const data = JSON.stringify(message);
wss.clients.forEach(client => {
if (client !== excludeWs && client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
}
// Gửi cho tất cả (kể cả sender):
broadcast({ type: 'announcement', text: 'Server restarting in 60s' });
// Gửi cho tất cả trừ sender:
broadcast({ type: 'message', text: msg.text, from: meta.name }, ws);
Rooms (Channels)
const rooms = new Map(); // roomName → Set<ws>
function joinRoom(ws, room) {
// Leave current room
const meta = clients.get(ws);
if (meta?.room) rooms.get(meta.room)?.delete(ws);
// Join new room
if (!rooms.has(room)) rooms.set(room, new Set());
rooms.get(room).add(ws);
if (meta) meta.room = room;
}
function broadcastToRoom(room, message, excludeWs = null) {
const members = rooms.get(room);
if (!members) return;
const data = JSON.stringify(message);
members.forEach(client => {
if (client !== excludeWs && client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
}
Connection Registry với Metadata
import { randomUUID } from 'crypto';
const clients = new Map(); // ws → { id, name, room, joinedAt }
wss.on('connection', (ws, req) => {
clients.set(ws, {
id: randomUUID(),
name: null, // set sau khi client gửi 'join' message
room: null,
joinedAt: Date.now()
});
ws.on('close', () => {
const meta = clients.get(ws);
if (meta?.room) rooms.get(meta.room)?.delete(ws);
clients.delete(ws);
broadcastPresence(meta?.room); // notify room members
});
});
Heartbeat — Detect Stale Connections
// TCP keepalive không đủ — network nào đó drop connection im lặng
// WebSocket-level heartbeat giải quyết vấn đề này
wss.on('connection', ws => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; }); // pong received = still alive
});
const heartbeatInterval = setInterval(() => {
wss.clients.forEach(ws => {
if (!ws.isAlive) {
console.log('Terminating stale connection');
return ws.terminate(); // force close, không gửi Close frame
}
ws.isAlive = false; // giả định dead cho đến khi nhận Pong
ws.ping(); // gửi Ping
});
}, 30_000); // mỗi 30s
wss.on('close', () => clearInterval(heartbeatInterval));
Graceful Shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM received — closing WebSocket connections...');
// Notify all clients
wss.clients.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) {
ws.close(1001, 'Server is shutting down');
}
});
// Wait for connections to close (max 5s)
await new Promise(resolve => {
const check = setInterval(() => {
if (wss.clients.size === 0) { clearInterval(check); resolve(); }
}, 100);
setTimeout(resolve, 5000); // force exit after 5s
});
server.close(() => process.exit(0));
});
Authentication
// Pattern 1: Token trong URL query (không khuyến nghị — leaked trong logs)
// wss://api.example.com/ws?token=abc123
// Pattern 2: Token trong custom header — KHÔNG work trên browser WebSocket API!
// Browser không cho set custom headers.
// Pattern 3 (khuyến nghị): Token trong first message
wss.on('connection', (ws, req) => {
ws.authenticated = false;
ws.on('message', data => {
const msg = JSON.parse(data);
if (!ws.authenticated) {
if (msg.type !== 'auth' || !verifyToken(msg.token)) {
ws.close(4001, 'Unauthorized');
return;
}
ws.authenticated = true;
ws.send(JSON.stringify({ type: 'auth-ok' }));
return;
}
handleMessage(ws, msg);
});
// Timeout nếu không auth trong 10s
setTimeout(() => {
if (!ws.authenticated) ws.close(4001, 'Auth timeout');
}, 10_000);
});
wss:// — WebSocket Secure
- ws:// — port 80, plaintext, chỉ dùng local development
- wss:// — port 443, TLS 1.2/1.3 tunnel, server cert verified trước WS handshake
- Sau khi TLS tunnel thiết lập, WS handshake nằm bên trong → proxy không đọc được content
- ws library Node.js: dùng
https.createServervới cert/key thay vìhttp.createServer
Origin Header Validation
Browser luôn gửi Origin header với WS request. Khác với XMLHttpRequest, WebSocket không bị hạn chế bởi Same-Origin Policy — browser vẫn cho phép cross-origin WS connection. Server PHẢI validate Origin nếu không muốn bất kỳ website nào connect vào.
const ALLOWED_ORIGINS = new Set([
'https://myapp.com',
'https://www.myapp.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null,
].filter(Boolean));
wss.on('headers', (headers, req) => {
const origin = req.headers.origin;
if (!ALLOWED_ORIGINS.has(origin)) {
// Không thể trả HTTP 403 ở đây vì headers đã được gửi
// Nhưng server vẫn có thể close connection ngay sau
console.warn('Rejected connection from origin:', origin);
}
});
// Hoặc validate trong connection handler:
wss.on('connection', (ws, req) => {
if (!ALLOWED_ORIGINS.has(req.headers.origin)) {
ws.close(1008, 'Origin not allowed');
return;
}
});
CSRF via WebSocket
Nếu server dùng cookie-based auth và không validate Origin: một trang evil.com có thể tạo WS connection đến api.yourapp.com — browser tự gửi cookie → attacker có thể thực hiện actions với quyền của victim.
Origin header (chặn cross-origin request). 2. Nếu dùng token auth (không cookie): không cần lo CSRF vì cross-origin page không đọc được token của domain khác.
Rate Limiting
const connectionsPerIp = new Map(); // ip → count
const MAX_CONNECTIONS_PER_IP = 10;
wss.on('connection', (ws, req) => {
const ip = req.socket.remoteAddress;
const count = (connectionsPerIp.get(ip) || 0) + 1;
if (count > MAX_CONNECTIONS_PER_IP) {
ws.close(1008, 'Too many connections');
return;
}
connectionsPerIp.set(ip, count);
ws.on('close', () => connectionsPerIp.set(ip, connectionsPerIp.get(ip) - 1));
});
// Message rate limit per connection:
const messageCount = new Map();
setInterval(() => messageCount.clear(), 1000); // reset mỗi giây
wss.on('connection', ws => {
ws.on('message', data => {
const count = (messageCount.get(ws) || 0) + 1;
messageCount.set(ws, count);
if (count > 100) { ws.close(1008, 'Rate limit exceeded'); return; }
handleMessage(ws, data);
});
});
// Max payload size (ws library):
const wss = new WebSocketServer({ server, maxPayload: 64 * 1024 }); // 64KB
Input Validation
ws.on('message', (rawData) => {
// 1. Parse safely
let msg;
try { msg = JSON.parse(rawData); }
catch { ws.close(1007, 'Invalid JSON'); return; }
// 2. Validate structure
if (typeof msg.type !== 'string') { ws.close(1008, 'Missing type'); return; }
// 3. Dispatch only known types
const handlers = { message: handleChat, join: handleJoin, typing: handleTyping };
const handler = handlers[msg.type];
if (!handler) { ws.send(JSON.stringify({ type: 'error', msg: 'Unknown type' })); return; }
handler(ws, msg);
});
Browser không tự động reconnect khi WebSocket connection bị drop. Phải tự implement. Pattern chuẩn: exponential backoff + jitter.
Exponential Backoff với Jitter
Timeline reconnect: delay tăng gấp đôi sau mỗi lần thất bại (+jitter ngẫu nhiên)
class ReconnectingWebSocket {
constructor(url, protocols = []) {
this.url = url;
this.protocols = protocols;
this.attempt = 0;
this.maxDelay = 30_000; // 30s cap
this.baseDelay = 1_000; // 1s base
this.ws = null;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url, this.protocols);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = e => { this.attempt = 0; this.onopen?.(e); };
this.ws.onmessage = e => this.onmessage?.(e);
this.ws.onerror = e => this.onerror?.(e);
this.ws.onclose = e => {
this.onclose?.(e);
if (!this.manualClose) this._scheduleReconnect();
};
}
_scheduleReconnect() {
const jitter = Math.random() * 1000;
const delay = Math.min(this.baseDelay * Math.pow(2, this.attempt), this.maxDelay) + jitter;
this.attempt++;
console.log(`Reconnecting in ${(delay/1000).toFixed(1)}s (attempt ${this.attempt})`);
setTimeout(() => this.connect(), delay);
}
send(data) { this.ws?.send(data); }
close(code) { this.manualClose = true; this.ws?.close(code); }
}
Server-side Heartbeat
// Vấn đề: TCP connection bị drop im lặng (network failure, NAT timeout, load balancer timeout)
// Không có Ping/Pong → server không biết client đã gone → memory leak
// Giải pháp: server gửi Ping mỗi 30s, terminate nếu không nhận Pong
wss.on('connection', ws => { ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); });
setInterval(() => {
wss.clients.forEach(ws => {
if (!ws.isAlive) { ws.terminate(); return; } // còn sống không? Không → terminate
ws.isAlive = false; // giả định dead
ws.ping(); // gửi probe
});
}, 30_000);
// Client-side heartbeat (cho thêm robustness):
let pingTimeout;
ws.onopen = () => schedulePing();
ws.onmessage = () => { clearTimeout(pingTimeout); schedulePing(); }; // reset on any msg
function schedulePing() {
pingTimeout = setTimeout(() => {
ws.send(JSON.stringify({ type: 'ping' })); // application-level ping
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) ws.close(); // no response → reconnect
}, 5000);
}, 25_000);
}
WebSocket hỗ trợ gửi/nhận raw binary data — không cần encode Base64 như với HTTP. Đây là lợi thế lớn cho game, IoT telemetry, và file transfer.
binaryType — Nhận binary như thế nào
ws.binaryType = 'arraybuffer'; // Nhận ArrayBuffer — synchronous access, preferred
ws.binaryType = 'blob'; // Nhận Blob — lazy, stream-friendly, good for large files
ws.onmessage = e => {
if (e.data instanceof ArrayBuffer) {
const view = new DataView(e.data);
const type = view.getUint8(0); // message type byte
const value = view.getFloat32(1, true); // float32 little-endian tại offset 1
} else if (e.data instanceof Blob) {
e.data.arrayBuffer().then(buf => { /* process */ });
}
};
Gửi Binary
// Typed Array
ws.send(new Uint8Array([0x01, 0x02, 0x03]));
// ArrayBuffer
const buf = new ArrayBuffer(12);
const view = new DataView(buf);
view.setUint8(0, 0x02); // message type: sensor data
view.setFloat32(4, 23.5, true); // temperature (little-endian)
view.setFloat32(8, 65.2, true); // humidity
ws.send(buf);
// Node.js server — nhận Buffer:
ws.on('message', (data, isBinary) => {
if (isBinary) {
const type = data.readUInt8(0);
const temp = data.readFloatLE(4);
}
});
Chunked File Transfer
// Sender (Client):
async function sendFile(ws, file) {
const CHUNK_SIZE = 16 * 1024; // 16KB per chunk
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
// 1. Send metadata
ws.send(JSON.stringify({
type: 'file-start',
name: file.name,
size: file.size,
mimeType: file.type,
totalChunks
}));
// 2. Send binary chunks
for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
const chunk = file.slice(offset, offset + CHUNK_SIZE);
ws.send(await chunk.arrayBuffer());
// Backpressure: wait if buffer full
if (ws.bufferedAmount > 4 * 1024 * 1024) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
// 3. Signal end
ws.send(JSON.stringify({ type: 'file-end' }));
}
// Receiver (Server/Client):
let fileChunks = [], fileInfo = null;
ws.onmessage = e => {
if (typeof e.data === 'string') {
const msg = JSON.parse(e.data);
if (msg.type === 'file-start') { fileChunks = []; fileInfo = msg; }
if (msg.type === 'file-end') {
const blob = new Blob(fileChunks, { type: fileInfo.mimeType });
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: url, download: fileInfo.name });
a.click();
}
} else {
fileChunks.push(e.data); // ArrayBuffer chunk
}
};
Binary Protocol Formats
| Format | Size vs JSON | Schema cần? | Phù hợp với |
|---|---|---|---|
| JSON | 1× (baseline) | Không | General purpose, human-readable |
| MessagePack | ~0.65–0.75× | Không (dynamic) | JSON-compatible, drop-in optimization |
| Protobuf | ~0.25–0.35× | Có (.proto file) | High-frequency messages, strict typing |
| FlatBuffers | ~0.3–0.4× | Có (.fbs file) | Zero-copy access, game engines |
| Raw binary | ~0.1–0.2× | Implicit | Sensor data, IoT (fixed-format payloads) |
Frame Overhead vs HTTP
| WebSocket Frame | HTTP/1.1 Request | |
|---|---|---|
| Header size | 2–14 bytes | 700–1,500 bytes |
| Payload overhead | ~0% (sub-percent) | ~700–1500 bytes per request |
| Connection setup | 1× per session (TCP + WS handshake) | 1× per request (hoặc keep-alive pool) |
• HTTP polling: 3,600 requests × 1,200 bytes header = 4.32 MB overhead
• WebSocket: 3,600 frames × 6 bytes frame header = 21.6 KB overhead
→ WebSocket hiệu quả hơn 200× về header overhead.
Horizontal Scaling — Vấn đề và Giải pháp
Vấn đề cốt lõi: WebSocket là stateful — connection gắn với một server process cụ thể. Khi scale lên nhiều server instances, message từ client A đến server 1 cần được forward đến client B đang connect server 2.
Giải pháp 1: Sticky Sessions
Load balancer route client đến cùng server instance dựa vào IP hash hoặc session cookie.
Ưu: Đơn giản, không cần thay đổi app code.
Nhược: Instance bị quá tải không tự rebalance được. Server restart → client reconnect ngẫu nhiên → session lost.
nginx: ip_hash;
# hoặc: hash $cookie_session;
Giải pháp 2: Pub/Sub Backend
Mỗi server subscribe một Redis channel. Khi cần broadcast: server publish lên Redis → tất cả instances nhận và forward cho connected clients của mình.
Ưu: Scalable, không cần sticky. Stateless servers.
Nhược: Thêm Redis dependency, latency cao hơn ~1–5ms.
redis.subscribe('room:general');
redis.on('message', (ch, msg) => {
broadcastLocal(ch, msg);
});
nginx WebSocket Proxy
upstream ws_backend {
server backend1:3000;
server backend2:3000;
ip_hash; # sticky sessions
}
server {
listen 443 ssl;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
location /ws {
proxy_pass http://ws_backend;
proxy_http_version 1.1; # bắt buộc cho Upgrade
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s; # 1 giờ — default 60s quá ngắn cho WS
proxy_send_timeout 3600s;
proxy_connect_timeout 10s;
}
}
Node.js Performance Numbers
- ~50,000–100,000 concurrent WS connections per process (với đủ RAM)
- Mỗi idle WS connection tốn ~10–20KB RAM (socket buffer + metadata)
- 100K connections × 15KB = ~1.5GB RAM → cần vertical scale hoặc cluster
- Node.js
clustermode: mỗi worker một process, dùng tất cả CPU cores - Throughput bottleneck thường là bandwidth/CPU parsing JSON, không phải WS overhead
Comparison Table
| WebSocket | SSE | Long-Polling | HTTP/2 Push | |
|---|---|---|---|---|
| Direction | Full-duplex ↕ | Server→Client ↓ | Server→Client ↓ | Server→Client ↓ |
| Protocol | WS (RFC 6455) | HTTP/1.1 + SSE | HTTP/1.1 | HTTP/2 |
| Connection | 1 persistent | 1 persistent | N short-lived | 1 persistent |
| Binary support | ✓ native | ✗ text only | ✗ | ✓ |
| Auto-reconnect | ✗ manual | ✓ built-in | ✓ | — |
| Browser support | ✓ All | ✓ (IE: ✗) | ✓ Universal | ✓ (HTTP/2) |
| Overhead/msg | 2–14 bytes | HTTP headers | Full HTTP req | Compressed header |
| Proxy/firewall | Đôi khi bị block | ✓ mọi proxy | ✓ mọi proxy | ✓ HTTP/2 proxy |
| Max connections | Browser limit/tab | 6/domain (H1) | 6/domain (H1) | Multiplexed |
Connection Patterns — Visualized
Cách mỗi technology thiết lập và duy trì kết nối (thời gian trên trục ngang)
Khi nào dùng gì?
| Use Case | Recommendation | Lý do |
|---|---|---|
| Chat, multiplayer game, collab editing | WebSocket | Cần bidirectional real-time, low latency |
| News feed, social notifications, live score | SSE | Server push only, text, auto-reconnect, proxy-friendly |
| IoT sensor → dashboard (unidirectional) | SSE hoặc WebSocket | SSE nếu text/JSON, WS nếu binary (tiết kiệm bandwidth) |
| CI/CD build log streaming | SSE | Đơn giản, text, một chiều |
| Trading platform, market data | WebSocket | Binary protocol, ultra-low latency, bidirectional |
| Legacy app, phải support IE11 | Long-polling | Universal fallback |
| API server với subscriptions | WebSocket + graphql-ws | GraphQL subscription pattern chuẩn hóa |
Danh sách kiểm tra trước khi deploy WebSocket lên production. Mỗi mục đều có lý do cụ thể.
- wss:// only — không bao giờ dùng ws:// trên public internet. TLS bắt buộc.
- Validate Origin header — chặn cross-origin connections không mong muốn. Ngăn CSRF attacks.
- Set maxPayload limit —
new WebSocketServer({ maxPayload: 64 * 1024 }). Ngăn DoS qua large frames. - Rate limit connections/IP — tối đa N connections per IP, X connections/minute. Ngăn connection flood.
- Rate limit messages/connection — tối đa 100–1000 msgs/second/connection tùy use case.
- Validate message schema — never trust client data. Validate type, structure, range.
- Authenticate trước khi xử lý data — close nếu không auth trong 10s, hoặc validate token trong HTTP headers.
- Exponential backoff reconnect trên client — base 1s, cap 30s, add jitter. Không reconnect ngay lập tức (gây thundering herd).
- Server heartbeat (Ping/Pong) — gửi Ping mỗi 30s, terminate connection nếu không nhận Pong. Detect stale connections.
- Graceful shutdown — khi nhận SIGTERM: gửi Close(1001) cho tất cả clients, đợi handshake hoàn tất (max 5s), rồi mới exit process.
- Handle onerror và onclose đúng cách — không để unhandled rejection. Log errors với context (client ID, room).
- Test network interruption — simulate network drop bằng Chrome DevTools → Offline. Verify reconnect hoạt động.
- Bật permessage-deflate cho JSON/text-heavy traffic. Giảm 50–80% bandwidth. Đánh giá CPU trade-off.
- Set nginx proxy_read_timeout ≥ 600s — default 60s timeout sẽ kill long-lived WebSocket connections.
- Dùng binary format cho high-frequency numerical data (sensor, game state) — tránh JSON parsing overhead.
- Monitor bufferedAmount trên client — implement backpressure để tránh OOM.
- Dùng ws.readyState === WebSocket.OPEN check trước khi send — tránh throwing trên closed socket.
- Horizontal scaling strategy — sticky sessions (nginx ip_hash) hoặc pub/sub backend (Redis). Phải chọn một trước khi production.
- nginx: proxy_http_version 1.1 + Upgrade headers — thiếu 2 dòng này → WebSocket upgrade thất bại.
- Monitor active connections —
wss.clients.size. Alert nếu vượt 80% capacity. - Monitor messages/second và error rate — spike bất thường → có thể là attack hoặc bug.
- Autobahn TestSuite (
wstest) — RFC 6455 compliance test. Chạy trước khi launch. github.com/crossbario/autobahn-testsuite - Test reconnection logic — network interruption (DevTools offline), server restart, load balancer failover.
- Load test — dùng
k6hoặcartilleryđể verify server chịu được N concurrent WS connections. - Test large messages gần maxPayload — verify server xử lý đúng, không crash.
nginx Config Template
upstream ws_backend {
ip_hash; # sticky sessions (hoặc Redis pub/sub cho stateless)
server 127.0.0.1:3000;
server 127.0.0.1:3001;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Static files
location / { root /var/www/app; try_files $uri /index.html; }
# WebSocket endpoint
location /ws {
proxy_pass http://ws_backend;
proxy_http_version 1.1; # ← bắt buộc
proxy_set_header Upgrade $http_upgrade; # ← bắt buộc
proxy_set_header Connection "upgrade"; # ← bắt buộc
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s; # ← 1 giờ, đủ cho long-lived connections
proxy_send_timeout 3600s;
proxy_connect_timeout 10s;
}
}
Các điểm cốt lõi cần nhớ
Bản chắt lọc từ 14 sections — những điểm quan trọng nhất về WebSocket, hay bị nhầm và hay gặp trong production.
Handshake & Framing
- Upgrade request cần:
Upgrade: websocket,Connection: Upgrade,Sec-WebSocket-Key(16 bytes base64) - Server reply: 101 Switching Protocols +
Sec-WebSocket-Accept= SHA-1(key + GUID), base64 - Frame header: tối thiểu 2 bytes — FIN/RSV/Opcode (1B) + MASK/Payload-len (1B)
- Masking bắt buộc client→server (4-byte key XOR payload); server→client không mask
- Fragmented: FIN=0 cho intermediate frames, FIN=1 cho frame cuối
Control Frames & Close
- Opcodes:
0x1=text,0x2=binary,0x8=close,0x9=ping,0xA=pong - Control frames: payload ≤ 125 bytes, không được fragment
- Close handshake: gửi close frame → đợi close reply → rồi mới đóng TCP — không close đột ngột
- Close codes: 1000 (normal), 1001 (going away), 1008 (policy violation), 1011 (server error)
- Nếu không nhận pong sau timeout → force close, coi là connection dropped
Security (WSS)
- WSS bắt buộc trong production —
ws://bị proxy/firewall block hoặc modify payload - Origin header trong upgrade request — server PHẢI validate Origin (chống CSRF WebSocket)
- Không có CORS cho WS — browser gửi Origin, server quyết định; khác HTTP, credentials không tự động bị block
- Authenticate trước khi upgrade: JWT trong query param hoặc header (
Sec-WebSocket-Protocoltrick) - DoS: limit connections/IP, rate limit messages, enforce max payload size
Reconnect & Heartbeat
- Exponential backoff:
delay = min(2^n × base, maxDelay) + jitter— jitter tránh thundering herd readyState: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSEDevent.wasClean=false= connection dropped (không qua close handshake) → nên reconnect- Application heartbeat: server gửi
{type:"ping"}→ client reply — khác WS-level ping/pong frame - Reconnect: restore state (subscribe lại rooms, re-auth) ngay sau khi open
Scaling
- WebSocket là stateful → cần sticky sessions (connection state nằm trong process heap)
- Pub/sub với Redis adapter: broadcast qua Redis channel → tất cả server instances nhận
- Memory: 1 connection ≈ 50–100 KB RAM server (tùy buffer size)
- Linux
ulimit -ngiới hạn file descriptors — cần tăng lên ≥ 65536 cho production - Nginx cần
proxy_read_timeout 3600svàproxy_http_version 1.1để không drop connection
WS vs Alternatives
- SSE: chỉ server→client, HTTP/1.1, auto-reconnect built-in — tốt hơn WS nếu chỉ cần 1 chiều
- Long polling: compatibility cao nhất nhưng latency cao, overhead mỗi request
- gRPC-Web streaming: binary, type-safe nhưng cần Envoy proxy và trình duyệt hỗ trợ hạn chế
- Dùng WebSocket khi: cần bidirectional real-time, latency quan trọng (<100ms), custom protocol
- HTTP/2 Server Push: deprecated — không phải real-time, không thay thế WebSocket
- Tại sao WebSocket cần masking? — Chống cache-poisoning attack: proxy cũ có thể cache WebSocket frames nếu không mask, confuse với HTTP responses
- Tại sao WebSocket là stateful làm phức tạp horizontal scaling? — Connection state (subscriptions, user identity) nằm trong RAM của 1 process → load balancer phải route đúng server (sticky session) hoặc dùng shared state (Redis)
- Khác nhau giữa ws:// và wss://? — wss = WebSocket qua TLS; ngoài encryption,
ws://bị nhiều corporate proxy và CDN block hoặc buffer toàn bộ response (vì nghĩ là HTTP) - Khi nào dùng SSE thay vì WebSocket? — Khi chỉ cần server→client push (notifications, live feeds) — SSE đơn giản hơn, HTTP/2 multiplexed, auto-reconnect, không cần special server
- WebSocket có CORS không? — Không — browser gửi Origin header, server có thể check nhưng không có preflight; không có built-in CORS enforcement như Fetch API