Network Programming · Part 6 — WebSockets & Real-Time Communication
WebSockets for real-time push: HTTP limits, the upgrade handshake, ws server + client in Node.js + TypeScript, heartbeats, and when to use SSE/polling instead — bilingual with exercises.
This is Part 6 of a 10-part series on network programming with Node.js + TypeScript {Đây là Phần 6 của series 10 bài về lập trình mạng với Node.js + TypeScript}. Parts 1–5 built the stack — TCP, UDP, DNS, and HTTP {Phần 1–5 đã xây stack — TCP, UDP, DNS, và HTTP}. Now we tackle real-time: the server pushing data to the client without waiting for a request {Giờ ta xử lý real-time: server đẩy dữ liệu tới client mà không cần chờ request}.
HTTP is brilliant for documents and APIs, but it is request/response and client-initiated {HTTP tuyệt cho tài liệu và API, nhưng nó là request/response và do client khởi tạo}. A chat app, live dashboard, or multiplayer game needs the server to say “here’s a new message” whenever it happens {App chat, dashboard live, hoặc game multiplayer cần server nói “có tin mới” bất cứ khi nào nó xảy ra}. WebSockets solve that with one persistent, full-duplex connection {WebSocket giải quyết bằng một kết nối bền vững, full-duplex}.
The problem HTTP cannot solve {Vấn đề HTTP không giải quyết được}
In Part 5 you built an HTTP server: the client sends a request, the server replies, and (unless keep-alive) the exchange ends {Ở Phần 5 bạn xây server HTTP: client gửi request, server trả lời, và (trừ keep-alive) trao đổi kết thúc}. For server push, developers historically hacked around this {Để server push, dev từng vá tạm bằng}:
- Polling — client asks “anything new?” every N seconds {Polling — client hỏi “có gì mới?” mỗi N giây}.
- Long-polling — client holds a request open until the server has data {Long-polling — client giữ request mở đến khi server có dữ liệu}.
- Server-Sent Events (SSE) — one HTTP stream, server → client only {SSE — một luồng HTTP, chỉ server → client}.
All of these still start from HTTP semantics {Tất cả vẫn bắt đầu từ ngữ nghĩa HTTP}. WebSocket upgrades the same TCP connection into a binary/text frame protocol where both sides send anytime {WebSocket nâng cấp cùng kết nối TCP thành giao thức frame nhị phân/text mà cả hai bên gửi bất cứ lúc nào}.
Key idea {Ý chính}: one TCP socket, one HTTP upgrade handshake, then a lightweight message channel — not a new request per message {một socket TCP, một bắt tay nâng cấp HTTP, rồi kênh message nhẹ — không phải request mới mỗi tin nhắn}.
The WebSocket upgrade handshake {Bắt tay nâng cấp WebSocket}
WebSocket does not open a random new port {WebSocket không mở port ngẫu nhiên}. It starts as HTTP on the same connection (usually 80/443 behind a reverse proxy) {Bắt đầu như HTTP trên cùng kết nối (thường 80/443 sau reverse proxy)}. The client sends a normal GET with special headers asking to switch protocols {Client gửi GET thường với header đặc biệt yêu cầu đổi giao thức}. The server either rejects (stays HTTP) or replies 101 Switching Protocols and from that moment both sides speak WebSocket frames — not HTTP {Server từ chối (giữ HTTP) hoặc trả 101 Switching Protocols và từ đó cả hai nói frame WebSocket — không còn HTTP}.
Client request (first and last HTTP message on this socket) {Request client (HTTP đầu và cuối trên socket này)}:
GET /chat HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Server response (if the upgrade is accepted) {Response server (nếu chấp nhận nâng cấp)}:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Key is a random nonce; Sec-WebSocket-Accept is a hash the client verifies so random HTTP proxies cannot accidentally “upgrade” {Sec-WebSocket-Key là nonce ngẫu nhiên; Sec-WebSocket-Accept là hash client xác minh để proxy HTTP ngẫu nhiên không “nâng cấp” nhầm}. After 101, the bytes on the wire are framed (opcode, mask bit, payload length) — libraries like ws handle that for you {Sau 101, byte trên dây là frame (opcode, bit mask, độ dài payload) — thư viện như ws lo giúp bạn}.
You can implement the handshake and frame parser yourself on top of node:http + node:net (educational, painful) {Bạn có thể tự implement bắt tay và parser frame trên node:http + node:net (học tốt, mệt)}. In production, ws is the practical choice for Node {Trong production, ws là lựa chọn thực tế cho Node}.
WebSocket server with ws {Server WebSocket với ws}
Install the library once {Cài thư viện một lần}:
npm install ws
npm install -D @types/ws
A minimal chat-style broadcast server — every message goes to every connected client {Server broadcast kiểu chat tối thiểu — mọi message tới mọi client đang kết nối}:
import { WebSocketServer, WebSocket } from 'ws';
const PORT = 8080;
const wss = new WebSocketServer({ port: PORT });
const clients = new Set<WebSocket>();
wss.on('connection', (ws, req) => {
console.log('upgrade from', req.socket.remoteAddress);
clients.add(ws);
ws.on('message', (data) => {
const text = data.toString();
console.log('received:', text);
// Broadcast to every other open socket
for (const client of clients) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(text);
}
}
});
ws.on('close', () => {
clients.delete(ws);
console.log('client disconnected, remaining:', clients.size);
});
ws.send(JSON.stringify({ type: 'welcome', message: 'connected to chat' }));
});
console.log(`WebSocket server listening on ws://localhost:${PORT}`);
Run with npx tsx ws-server.ts (or compile first) {Chạy bằng npx tsx ws-server.ts (hoặc compile trước)}. Key events {Sự kiện chính}:
'connection'— fires after the HTTP upgrade succeeds {bắn sau khi nâng cấp HTTP thành công}.'message'— a complete WebSocket frame payload (text or binary) {payload frame WebSocket hoàn chỉnh (text hoặc binary)}.ws.send()— push from server to client anytime {đẩy từ server tới client bất cứ lúc nào}.readyState === WebSocket.OPEN— only send when the socket is open {chỉ gửi khi socket đang mở}.
Browser client {Client trình duyệt}
Browsers expose WebSocket natively — no ws package {Trình duyệt có WebSocket sẵn — không cần package ws}. Save as client.html and open it while the server runs {Lưu client.html và mở khi server đang chạy}:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8" /><title>WS demo</title></head>
<body>
<input id="msg" placeholder="type a message" />
<button id="send">Send</button>
<ul id="log"></ul>
<script>
const log = document.getElementById('log');
const ws = new WebSocket('ws://localhost:8080');
ws.onopen = () => append('connected');
ws.onmessage = (ev) => append('← ' + ev.data);
ws.onclose = () => append('disconnected');
document.getElementById('send').onclick = () => {
const input = document.getElementById('msg');
ws.send(input.value);
append('→ ' + input.value);
input.value = '';
};
function append(text) {
const li = document.createElement('li');
li.textContent = text;
log.appendChild(li);
}
</script>
</body>
</html>
Open two browser tabs — messages typed in one tab appear in the other {Mở hai tab — tin gõ ở tab này hiện ở tab kia}. That is full-duplex server push without polling {Đó là server push full-duplex không cần polling}.
Node.js client (testing & scripts) {Client Node.js (test & script)}
The ws package also provides a client for integration tests and CLI tools {Package ws cũng có client cho integration test và CLI}:
import WebSocket from 'ws';
const ws = new WebSocket('ws://localhost:8080');
ws.on('open', () => {
console.log('connected');
ws.send('hello from node client');
});
ws.on('message', (data) => {
console.log('server said:', data.toString());
});
ws.on('close', (code, reason) => {
console.log('closed', code, reason.toString());
});
Use this pattern in CI to assert your server emits the right events {Dùng pattern này trong CI để assert server phát đúng sự kiện}. For WSS (TLS), pass \{ rejectUnauthorized: false \} only in dev with self-signed certs — never in production {Với WSS (TLS), chỉ truyền \{ rejectUnauthorized: false \} ở dev với cert tự ký — không bao giờ ở production}.
Ping/pong heartbeats {Nhịp tim ping/pong}
TCP connections can die silently — laptop sleep, NAT timeout, Wi-Fi drop — with no 'close' event for minutes {Kết nối TCP có thể chết im lặng — máy ngủ, NAT timeout, mất Wi-Fi — không có 'close' trong nhiều phút}. WebSocket defines ping/pong control frames; the ws library can emit them automatically {WebSocket định nghĩa frame điều khiển ping/pong; ws có thể phát tự động}:
import { WebSocketServer, WebSocket } from 'ws';
const wss = new WebSocketServer({
port: 8080,
perMessageDeflate: false,
});
const HEARTBEAT_MS = 30_000;
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true;
});
ws.on('message', (data) => {
// handle app messages…
console.log('msg:', data.toString());
});
});
const interval = setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) {
ws.terminate(); // dead connection — force close
continue;
}
ws.isAlive = false;
ws.ping();
}
}, HEARTBEAT_MS);
wss.on('close', () => clearInterval(interval));
Clients that use the browser WebSocket API respond to server pings automatically {Client dùng API WebSocket trình duyệt trả lời ping server tự động}. Reconnection (backoff, resuming session state, missed messages) is app logic — we cover robust patterns in Part 10 {Kết nối lại (backoff, khôi phục session, tin nhắn bỏ lỡ) là logic app — ta đi sâu ở Phần 10}.
Real-time options compared {So sánh các lựa chọn real-time}
| Approach | Direction | Connection | Complexity | Best when |
|---|---|---|---|---|
| Polling | Client pulls | New HTTP request each tick | Lowest | Rare updates, prototypes, legacy-only clients |
| Long-polling | Client pulls (held open) | HTTP request per event batch | Medium | Older browsers, simple “wait for update” |
| SSE | Server → client only | One long-lived HTTP stream | Medium | Live feeds, notifications, stock tickers (read-only push) |
| WebSocket | Full duplex | One upgraded TCP socket | Medium–high | Chat, games, collaborative editing, bidirectional RPC |
Rule of thumb {Quy tắc ngón tay cái}: need client → server and server → client at low latency? → WebSocket {cần client → server và server → client độ trễ thấp? → WebSocket}. Need only server push over plain HTTP? → SSE is simpler and rides normal HTTP/2 infra {chỉ cần server push qua HTTP thường? → SSE đơn giản hơn và chạy trên infra HTTP/2}. Polling is fine when updates are infrequent and simplicity beats latency {Polling ổn khi cập nhật thưa và đơn giản quan trọng hơn độ trễ}.
Mistakes beginners make {Lỗi người mới hay mắc}
- ❌ Trying to server-push over HTTP with
res.write()on a normal request — proxies and clients expect a complete response; use WebSocket or SSE instead {Cố push từ server qua HTTP bằngres.write()trên request thường — proxy và client mong response hoàn chỉnh; dùng WebSocket hoặc SSE}. - ❌ No heartbeats — dead TCP connections sit in your
clientsset until something else fails {Không heartbeat — kết nối TCP chết nằm trongclientsđến khi thứ khác lỗi}. - ❌ No auth on the upgrade — anyone who can hit
ws://host/chatjoins; validate cookies/JWT during'connection'(or reject the upgrade in a customverifyClient) {Không auth khi nâng cấp — ai hitws://host/chatđều vào; validate cookie/JWT lúc'connection'(hoặc từ chối nâng cấp trongverifyClient)}. - ❌ Assuming delivery across reconnects — WebSocket is reliable while connected, but disconnects drop in-flight state unless you add IDs, acks, or a replay buffer (Part 10) {Tưởng giao hàng qua reconnect — WebSocket tin cậy khi đang kết nối, nhưng disconnect mất trạng thái đang bay trừ khi thêm ID, ack, hoặc replay buffer (Phần 10)}.
Exercises {Bài tập}
Try each before opening the solution {Thử từng bài trước khi mở lời giải}.
- Extend the broadcast server so the sender does not receive their own message (hint: you already skip
client !== ws— verify it works with two Node clients) {Mở rộng server broadcast để người gửi không nhận lại tin của mình (gợi ý: đã bỏ quaclient !== ws— kiểm tra với hai client Node)}. - Add a
/statusHTTP endpoint on the same port usingwss+node:http— return JSON\{ connections: number \}(readwsdocs forWebSocketServer+serveroption) {Thêm endpoint HTTP/statuscùng port dùngwss+node:http— trả JSON\{ connections: number \}(đọc docswsvề optionserver)}. - Log the close code when a client disconnects; look up what code
1000vs1006mean in the RFC {Log mã đóng khi client ngắt; tra nghĩa1000vs1006trong RFC}.
Solution {Lời giải}
import { createServer } from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';
const clients = new Set<WebSocket>();
const httpServer = createServer((req, res) => {
if (req.url === '/status') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ connections: clients.size }));
return;
}
res.writeHead(404).end();
});
const wss = new WebSocketServer({ server: httpServer });
wss.on('connection', (ws) => {
clients.add(ws);
ws.on('message', (data) => {
const text = data.toString();
for (const client of clients) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(text);
}
}
});
ws.on('close', (code, reason) => {
clients.delete(ws);
// 1000 = normal closure; 1006 = abnormal (no close frame — often network drop)
console.log('closed', code, reason.toString());
});
});
httpServer.listen(8080, () => {
console.log('HTTP + WS on http://localhost:8080');
});Exercise 1: the client !== ws check ensures the originating socket is excluded — run two ws clients; only the peer receives the echo broadcast {Bài 1: kiểm tra client !== ws loại socket gốc — chạy hai client ws; chỉ peer nhận broadcast}. Exercise 2: attaching WebSocketServer to an existing http.Server is how you share port 8080 for both HTTP and WS upgrade {Bài 2: gắn WebSocketServer vào http.Server có sẵn là cách dùng chung port 8080 cho HTTP và nâng cấp WS}. Exercise 3: 1000 means both sides agreed to close cleanly; 1006 means the TCP connection vanished without a WebSocket close handshake {Bài 3: 1000 là đóng sạch hai bên đồng ý; 1006 là TCP mất mà không có bắt tay đóng WebSocket}.
Takeaway {Điều cốt lõi}
HTTP is pull; real-time apps need push {HTTP là kéo; app real-time cần đẩy}. WebSocket upgrades one TCP connection from HTTP to a persistent, full-duplex frame channel — client and server send whenever they want {WebSocket nâng cấp một kết nối TCP từ HTTP thành kênh frame bền vững, full-duplex — client và server gửi bất cứ lúc nào}. Use ws in Node, native WebSocket in browsers, add ping/pong to reap dead peers, and pick SSE or polling when you do not need bidirectional traffic {Dùng ws ở Node, WebSocket native ở trình duyệt, thêm ping/pong để dọn peer chết, và chọn SSE hoặc polling khi không cần hai chiều}. Next in Part 7: TLS — encrypting everything we have built so far {Tiếp theo Phần 7: TLS — mã hóa mọi thứ ta đã xây}.