jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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/responsedo 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/textcả 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 Server GET … Upgrade: websocket 101 Switching Protocols frames ↔ frames (full-duplex) one handshake, then both sides push messages anytime
HTTP upgrade once, then bidirectional frames on the same TCP socket

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}

ApproachDirectionConnectionComplexityBest when
PollingClient pullsNew HTTP request each tickLowestRare updates, prototypes, legacy-only clients
Long-pollingClient pulls (held open)HTTP request per event batchMediumOlder browsers, simple “wait for update”
SSEServer → client onlyOne long-lived HTTP streamMediumLive feeds, notifications, stock tickers (read-only push)
WebSocketFull duplexOne upgraded TCP socketMedium–highChat, 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 → serverserver → 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ằng res.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 clients set until something else fails {Không heartbeat — kết nối TCP chết nằm trong clients đến khi thứ khác lỗi}.
  • No auth on the upgrade — anyone who can hit ws://host/chat joins; validate cookies/JWT during 'connection' (or reject the upgrade in a custom verifyClient) {Không auth khi nâng cấp — ai hit ws://host/chat đều vào; validate cookie/JWT lúc 'connection' (hoặc từ chối nâng cấp trong verifyClient)}.
  • 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}.

  1. 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ỏ qua client !== ws — kiểm tra với hai client Node)}.
  2. Add a /status HTTP endpoint on the same port using wss + node:http — return JSON \{ connections: number \} (read ws docs for WebSocketServer + server option) {Thêm endpoint HTTP /status cùng port dùng wss + node:http — trả JSON \{ connections: number \} (đọc docs ws về option server)}.
  3. Log the close code when a client disconnects; look up what code 1000 vs 1006 mean in the RFC {Log mã đóng khi client ngắt; tra nghĩa 1000 vs 1006 trong 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}.