WebSocket Deep Dive

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)

TCP Connect 3-way handshake SYN / SYN-ACK / ACK HTTP Upgrade GET + Upgrade: websocket ← 101 Switching Protocols WS Open readyState: OPEN (1) onopen event fires Messages Full-duplex WS frames Text / Binary / Ping-Pong Close Close frame exchange TCP connection closed
01 WebSocket là gì?

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

Application
WebSocket (ws:// / wss://) — RFC 6455 — giao thức bạn code
Session (boot)
HTTP/1.1 — chỉ dùng cho handshake upgrade, sau đó không còn liên quan
Security
TLS 1.2/1.3 — bắt buộc cho wss://, cung cấp encryption + authentication
Transport
TCP — reliable, ordered delivery. WebSocket không thêm reliability — TCP đã lo
Network
IP — routing và addressing

WebSocket vs HTTP vs TCP thuần

HTTP/1.1WebSocketRaw TCP
ConnectionStateless, request-responsePersistent, statefulPersistent, stateful
DirectionClient→Server onlyFull-duplexFull-duplex
Overhead/msg700–1500 bytes headers2–14 bytes frame header0 bytes (raw)
Browser supportNativeNative (2012+)Không (vì bảo mật)
Proxies/CDNTransparentCần cấu hìnhThường bị block
Binary dataBase64 encodeNative binary framesNative

Đặ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
⚠ Khi nào KHÔNG nên dùng WebSocket
Nếu data chỉ chạy một chiều (server → client) và là text: dùng SSE — đơn giản hơn, auto-reconnect, thân thiện với proxy. Nếu cần request-response thông thường: cứ dùng HTTP/2. Xem thêm Section 13.
02 HTTP Upgrade Handshake

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');
Magic GUID
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

Client Browser / ws lib Server Node.js / nginx ① TCP 3-Way Handshake SYN → SYN-ACK ← ACK ② HTTP Upgrade Request GET /ws HTTP/1.1 · Upgrade: websocket · Sec-WebSocket-Key: … ③ 101 Switching Protocols 101 · Sec-WebSocket-Accept: s3pPLMBi… · Connection upgraded ✓ ④ WebSocket Frame Exchange (full-duplex) ws.send("hello") → Text frame (opcode 0x1) ← Text frame · onmessage: e.data = "world" ⑤ Close Handshake Close frame (opcode 0x8, code 1000) Echo Close frame → TCP FIN → connection closed

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 Upgrade header → 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.
03 Framing Layer — RFC 6455

Đâ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)

Byte 0 Byte 1 Opt. Data FIN 1 bit final? RSV1 ext. RSV2 ext. RSV3 ext. OPCODE 4 bits — 0x0 Continuation · 0x1 Text · 0x2 Binary · 0x8 Close · 0x9 Ping · 0xA Pong MASK 1 bit C→S:1 PAYLOAD LEN 7 bits — 0–125: direct · 126: next 2 bytes (uint16) · 127: next 8 bytes (uint64) Extended Payload Length (16 bits nếu Payload Len=126 · 64 bits nếu =127) Masking Key (32 bits nếu MASK=1) PAYLOAD DATA — 0 đến 2⁶³ bytes

Opcode Table

OpcodeTênLoạiMô tả
0x0ContinuationDataFrame tiếp theo của fragmented message
0x1TextDataUTF-8 encoded text payload
0x2BinaryDataRaw binary payload (ArrayBuffer)
0x3–7ReservedDành cho extension tương lai (non-control)
0x8CloseControlKhởi tạo close handshake
0x9PingControlKeepalive probe — peer PHẢI reply Pong
0xAPongControlReply cho Ping, echo cùng payload
0xB–FReservedDà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ĩaTổng bytes header
0–125Payload length chính là con số này2 bytes (+ 4 nếu masked)
126Đọc thêm 2 bytes tiếp theo (uint16 big-endian) → max 65,535 bytes4 bytes (+ 4 nếu masked)
127Đọc thêm 8 bytes tiếp theo (uint64 big-endian) → max ~9.2 EB10 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.
04 Control 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.

Ràng buộc của Control Frames
Payload ≤ 125 bytes · FIN=1 (không được fragment) · Có thể chen giữa data frames đang fragmented

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

CodeTênÝ nghĩa
1000Normal ClosureĐóng bình thường, tác vụ hoàn thành
1001Going AwayServer restart / browser tab đóng / navigate away
1002Protocol ErrorVi phạm WebSocket protocol
1003Unsupported DataNhận data type không hỗ trợ (ví dụ: binary khi chỉ expect text)
1005No Status (reserved)Không được gửi trong Close frame — chỉ dùng internally
1006Abnormal (reserved)Không được gửi — dành cho khi connection drop không có Close frame
1007Invalid Frame DataText frame chứa non-UTF-8 data
1008Policy ViolationMessage vi phạm chính sách ứng dụng
1009Message Too BigMessage vượt quá kích thước cho phép
1011Internal ErrorServer gặp unexpected condition
4000–4999ApplicationDà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"
05 Extensions — permessage-deflate

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

ParameterDefaultÝ nghĩa
client_max_window_bits15LZ77 sliding window size (8–15). Lớn hơn = nén tốt hơn, tốn RAM hơn
server_max_window_bits15Window size phía server
client_no_context_takeoveroffReset LZ77 context sau mỗi message (ít RAM, nén kém hơn)
server_no_context_takeoveroffTươ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).

Khi nào bật compression?
Bật cho JSON, HTML text, repeated structured data — giảm 50–80% payload size. Tắt cho JPEG/PNG/video/encrypted data (đã nén sẵn — compression thêm chỉ làm chậm, không giảm được). Break-even point: payload > ~150 bytes.

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.

06 Subprotocols

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

SubprotocolSpecUse case
STOMPstomp.github.ioMessage broker (ActiveMQ, RabbitMQ) — publish/subscribe qua WS
MQTTRFC 7252 + MQTT over WSIoT lightweight messaging
WAMPwamp.wsRPC + Pub/Sub trong 1 protocol
graphql-wsgithub.com/enisdenjoGraphQL subscriptions
json-rpcjsonrpc.orgRemote procedure calls over WS
ocpp2.0.1openchargealliance.orgEV 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)
07 Browser WebSocket API

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

0 CONNECTING 1 OPEN 2 CLOSING 3 CLOSED

Chuyển đổi trạng thái — các đường dashed là error paths

CONNECTING readyState: 0 handshake… onopen OPEN readyState: 1 send() / recv ws.close() CLOSING readyState: 2 handshake… onclose CLOSED readyState: 3 final state onerror — connection refused / server down network error / server crash

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);
}
08 Server-Side Patterns (Node.js + ws)

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);
});
09 Security — WSS & Best Practices
🔴 Rule #1
Luôn dùng wss:// trên production. ws:// là cleartext TCP — mọi thứ bạn gửi đều bị đọc được trên đường truyền. wss:// = WebSocket over TLS, y hệt HTTPS.

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.createServer vớ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.

Defense
1. Validate 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);
});
10 Reconnection & Heartbeat

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)

1 s 2 s 4 s 8 s + jitter (max 30 s) t=0 +1s +2s +4s connected! Jitter = Math.random() × 1000ms — ngăn "thundering herd" khi server restart delay = min(base × 2^attempt, maxDelay) + jitter
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);
}
11 Binary Messaging

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

FormatSize vs JSONSchema cần?Phù hợp với
JSON1× (baseline)KhôngGeneral 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×ImplicitSensor data, IoT (fixed-format payloads)
12 Performance & Scaling

Frame Overhead vs HTTP

WebSocket FrameHTTP/1.1 Request
Header size2–14 bytes700–1,500 bytes
Payload overhead~0% (sub-percent)~700–1500 bytes per request
Connection setup1× per session (TCP + WS handshake)1× per request (hoặc keep-alive pool)
Ví dụ tính toán
Gửi "ping" (4 bytes) mỗi giây trong 1 giờ:
• 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 cluster mode: 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
13 WebSocket vs Alternatives

Comparison Table

WebSocketSSELong-PollingHTTP/2 Push
DirectionFull-duplex ↕Server→Client ↓Server→Client ↓Server→Client ↓
ProtocolWS (RFC 6455)HTTP/1.1 + SSEHTTP/1.1HTTP/2
Connection1 persistent1 persistentN short-lived1 persistent
Binary support✓ native✗ text only
Auto-reconnect✗ manual✓ built-in
Browser support✓ All✓ (IE: ✗)✓ Universal✓ (HTTP/2)
Overhead/msg2–14 bytesHTTP headersFull HTTP reqCompressed header
Proxy/firewallĐôi khi bị block✓ mọi proxy✓ mọi proxy✓ HTTP/2 proxy
Max connectionsBrowser limit/tab6/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)

HTTP Long-Polling Server-Sent Events WebSocket Client Server GET /events 200 event1 GET /events 200 event2 GET /events …wait… 200 event3 N connections/session high header overhead Client Server GET /events (persistent) data: event1\n\n data: event2\n\n data: event3\n\n data: event4\n\n … 1 persistent connection auto-reconnect built-in server→client only · text only Client Server HTTP Upgrade → 101 ws.send("hello") ← onmessage: "world" send(new ArrayBuffer(…)) ← binary data push 1 persistent connection full-duplex · text + binary manual reconnect required

Khi nào dùng gì?

Use CaseRecommendationLý do
Chat, multiplayer game, collab editingWebSocketCần bidirectional real-time, low latency
News feed, social notifications, live scoreSSEServer push only, text, auto-reconnect, proxy-friendly
IoT sensor → dashboard (unidirectional)SSE hoặc WebSocketSSE nếu text/JSON, WS nếu binary (tiết kiệm bandwidth)
CI/CD build log streamingSSEĐơn giản, text, một chiều
Trading platform, market dataWebSocketBinary protocol, ultra-low latency, bidirectional
Legacy app, phải support IE11Long-pollingUniversal fallback
API server với subscriptionsWebSocket + graphql-wsGraphQL subscription pattern chuẩn hóa
SSE thường bị đánh giá thấp
Nếu use case của bạn chỉ cần server push (server → client) và data là text/JSON: SSE thường là lựa chọn tốt hơn WebSocket. SSE: auto-reconnect, hoạt động qua mọi proxy/CDN, không cần upgrade header, native event IDs cho message recovery. Chỉ chuyển sang WebSocket khi cần bidirectional communication hoặc binary frames.
14 Production Checklist

Danh sách kiểm tra trước khi deploy WebSocket lên production. Mỗi mục đều có lý do cụ thể.

🔒 Security
  • 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 limitnew 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.
🔁 Reliability
  • 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.
⚡ Performance
  • 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.
📈 Scaling
  • 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 connectionswss.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.
🧪 Testing
  • 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 k6 hoặc artillery để 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-Protocol trick)
  • 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=CLOSED
  • event.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 -n giới hạn file descriptors — cần tăng lên ≥ 65536 cho production
  • Nginx cần proxy_read_timeout 3600sproxy_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
Câu hỏi hay gặp trong interviews
  • 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