Giao tiếp Real-timeReal-time Communication
Thuộc bộ kiến thức Backend Roadmap.
Tổng quan
Web cổ điển được xây trên mô hình HTTP request/response: client hỏi, server trả lời, rồi kết thúc. Mô hình này là client-initiated — server chỉ được “nói” khi bị “hỏi”. Điều đó hoàn hảo khi tải một trang hay submit một form, nhưng nó sụp đổ ngay khi server có tin mà client chưa hề hỏi tới: một tin nhắn chat mới, một biến động giá cổ phiếu, một thông báo “đơn hàng đã giao”, hay con trỏ của người dùng khác đang di chuyển trong một tài liệu chung.
Real-time communication là tập hợp các kỹ thuật cho phép server đẩy (push) dữ liệu xuống client (và thường là chiều ngược lại, với độ trễ thấp) để UI phản ánh thay đổi ngay khi chúng xảy ra, thay vì đợi lần refresh trang kế tiếp. Không có một “real-time protocol” duy nhất; thay vào đó là cả một phổ các cách tiếp cận, mỗi cách đánh đổi khác nhau giữa latency, overhead, độ phức tạp và chi phí hạ tầng:
- Short polling — client lặp đi lặp lại câu hỏi “có gì mới không?” theo một khoảng thời gian.
- Long polling — client hỏi, và server giữ request mở cho đến khi có gì đó để trả lời.
- Server-Sent Events (SSE) — một HTTP response sống lâu (long-lived) stream các event theo chiều server→client.
- WebSockets — một kết nối TCP bền vững, full-duplex cho trao đổi hai chiều.
- WebTransport / HTTP/3 datagrams — một transport hiện đại, multiplexed trên nền QUIC.
Note này giải thích từng kỹ thuật hoạt động ra sao, khi nào nên dùng cái nào, và — quan trọng nhất — cách scale hệ thống real-time trên nhiều server, đây chính là chỗ mà đa số team bị bất ngờ.
Vì sao “real-time” là một phổ, không phải một công tắc
“Real-time” trong ngữ cảnh backend hiếm khi mang nghĩa hard real-time (đảm bảo cỡ microsecond). Nó nghĩa là độ trễ cảm nhận thấp — cập nhật đến trong khoảng vài chục đến vài trăm mili-giây. Kỹ thuật phù hợp phụ thuộc vào:
- Hướng (direction) — bạn chỉ cần server→client, hay cần full-duplex?
- Tần suất — một event mỗi phút, hay hàng nghìn event mỗi giây?
- Fan-out — một người nhận, hay một triệu subscriber?
- Đảm bảo phân phối (delivery guarantees) — “best effort” là đủ, hay cần đúng thứ tự và không mất mát?
- Hạ tầng — proxy, load balancer và CDN của bạn có giữ được kết nối mở không?
Kiến thức nền tảng
Vấn đề cốt lõi: HTTP là client-initiated
Trong HTTP/1.1 thuần, một kết nối mang một request và một response (hoặc vài cái tuần tự nhờ keep-alive). Server không có kênh nào để chủ động gửi tin. Nếu một giá trị thay đổi trên server tại thời điểm T, client sẽ không biết cho đến lần request kế tiếp của nó. Mọi kỹ thuật bên dưới đều là một chiến lược để thu hẹp khoảng cách đó — hoặc bằng cách hỏi thường xuyên hơn, hoặc bằng cách giữ một request mở, hoặc bằng cách nâng cấp lên một transport thực sự hai chiều.
TCP, kết nối, và vì sao chúng tốn tài nguyên
Mọi kỹ thuật real-time trừ short polling đều dựa vào việc giữ một kết nối mở. Một kết nối được giữ tiêu tốn một file descriptor và bộ nhớ trên server, một slot trong mỗi thành phần trung gian (load balancer, reverse proxy), và thường là một thread trừ khi server dùng I/O bất đồng bộ (async/event-driven). Đây là lý do real-time ở quy mô lớn về bản chất là một bài toán quản lý kết nối (connection management), chứ không chỉ là chọn protocol — một server mạnh có thể phục vụ hàng triệu request HTTP stateless mỗi phút nhưng có thể chạm trần ở vài chục nghìn WebSocket mở đồng thời.
Latency, overhead và độ tươi của dữ liệu
Ba yếu tố này căng kéo lẫn nhau:
- Poll thường xuyên → dữ liệu tươi, nhưng lãng phí request và tải khi chẳng có gì thay đổi.
- Poll thưa → rẻ, nhưng dữ liệu cũ (stale).
- Giữ kết nối → tươi và rẻ trên đường truyền, nhưng đắt về tài nguyên bị chiếm giữ và khó scale/vận hành hơn.
Khái niệm chính
Short polling
Client hỏi theo một khoảng cố định. Mỗi lần poll là một HTTP request độc lập; server trả lời ngay lập tức, hoặc là dữ liệu mới hoặc là “chưa có gì”.
// Client: ask every 3 seconds
async function shortPoll() {
const res = await fetch("/api/messages?since=" + lastSeenId);
const data = await res.json();
if (data.length) render(data);
}
setInterval(shortPoll, 3000);
Cách hoạt động: cực đơn giản — chỉ là các GET lặp lại. Đánh đổi: đơn giản tối đa và chạy ở mọi nơi (không cần server hỗ trợ đặc biệt), nhưng lãng phí request khi không có gì mới, và latency tệ nhất bằng đúng khoảng poll. Muốn tươi gấp đôi thì lượng request cũng gấp đôi. Tốt cho các kiểm tra trạng thái tần suất thấp, fan-out thấp; tệ cho chat.
Long polling
Client gửi một request, và server không trả lời ngay. Nó giữ request mở cho đến khi có dữ liệu (hoặc hết timeout), rồi mới trả lời. Client lập tức mở một request mới. Cách này cho phân phối gần real-time trên nền HTTP thông thường.
// Client: reconnect as soon as each response arrives
async function longPoll() {
try {
const res = await fetch("/api/messages/stream?since=" + lastSeenId);
const data = await res.json();
if (data.length) { render(data); lastSeenId = data.at(-1).id; }
} catch (e) {
await sleep(1000); // back off on error
}
longPoll(); // immediately re-establish
}
longPoll();
// Server (Express-ish): hold the response until an event or timeout
app.get("/api/messages/stream", async (req, res) => {
const since = Number(req.query.since);
const timeout = setTimeout(() => res.json([]), 25000); // avoid proxy idle-kill
emitter.once("message", (msg) => {
if (msg.id > since) { clearTimeout(timeout); res.json([msg]); }
});
});
Đánh đổi: tươi hơn hẳn short polling và không cần protocol đặc biệt, nhưng mỗi message vẫn phải trả cái giá của một round-trip HTTP đầy đủ (headers, reconnect), và việc giữ request mở làm bận tài nguyên server. Đây là “con ngựa kéo cày” trước thời WebSockets/SSE và vẫn là một fallback vững chắc.
Server-Sent Events (SSE)
SSE là một stream một chiều (unidirectional) server→client trên một HTTP response sống lâu duy nhất với Content-Type: text/event-stream. API EventSource có sẵn trong browser lo việc kết nối, parse event, và — một tính năng then chốt — tự động reconnect rồi resume bằng header Last-Event-ID.
// Client: one line to subscribe
const es = new EventSource("/api/events");
es.onmessage = (e) => console.log("data:", e.data);
es.addEventListener("price", (e) => updateTicker(JSON.parse(e.data)));
es.onerror = () => {/* EventSource auto-reconnects; no code needed */};
// Server: stream text/event-stream frames
app.get("/api/events", (req, res) => {
res.set({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
const send = (event, data, id) => {
if (id) res.write(`id: ${id}\n`);
if (event) res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`); // blank line ends the event
};
const timer = setInterval(() => send("price", { sym: "AAPL", px: 199.9 }, Date.now()), 1000);
req.on("close", () => clearInterval(timer));
});
Định dạng đường truyền là text thuần: các dòng như data:, event:, id:, và retry:, với một dòng trống kết thúc mỗi event. Điểm mạnh: cực đơn giản, chạy trên HTTP thông thường (đi qua được đa số proxy và HTTP/2), có sẵn reconnect và event ID, và text dễ debug. Giới hạn: chỉ server→client (gửi dữ liệu client→server bằng HTTP request thường), chỉ text (không có binary nếu không encode), và trên HTTP/1.1 browser giới hạn số kết nối mỗi domain (~6) — một vấn đề thực tế nhưng đã được HTTP/2 multiplexing giải quyết phần lớn. Use case: live dashboard, notification, feed, ticker giá/cổ phiếu, stream log, stream token của AI.
WebSockets
WebSockets cung cấp một kết nối bền vững, full-duplex: sau một handshake HTTP ban đầu, cả hai phía có thể gửi message bất kỳ lúc nào trên cùng một kết nối TCP. Được định nghĩa bởi RFC 6455.
Upgrade handshake: client gửi một HTTP GET kèm Upgrade: websocket, Connection: Upgrade, và một Sec-WebSocket-Key ngẫu nhiên. Nếu server đồng ý, nó trả về 101 Switching Protocols cùng một Sec-WebSocket-Accept được suy ra từ key đó (SHA-1 của key + một GUID cố định, rồi encode base64). Sau khi có 101, các byte trên đường truyền không còn là HTTP nữa — chúng là WebSocket frame.
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Frames: dữ liệu đi trong các message được đóng frame nhỏ, mang một opcode (text, binary, ping, pong, close), một độ dài payload, và — theo chiều client→server — một masking key. Các control frame (ping/pong) giữ kết nối sống và phát hiện peer đã chết.
// Server: Node.js with the "ws" library
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws) => {
ws.send("welcome");
ws.on("message", (buf) => {
const msg = buf.toString();
// broadcast to everyone
for (const client of wss.clients) {
if (client.readyState === client.OPEN) client.send(msg);
}
});
});
// Client: browser WebSocket API
const ws = new WebSocket("wss://example.com/chat");
ws.onopen = () => ws.send("hello");
ws.onmessage = (e) => console.log("got:", e.data);
ws.onclose = () => console.log("closed"); // no auto-reconnect — you implement it
Khi nào dùng: tương tác hai chiều thực sự, độ trễ thấp — chat, game nhiều người, collaborative editing, giao dịch trực tuyến, điều khiển IoT. Cần lưu ý: không có reconnect sẵn (khác SSE, bạn tự viết backoff), bạn phải gửi ping/heartbeat định kỳ để phát hiện kết nối hỏng và đánh bại idle timeout, dùng wss:// (TLS) trong production, và luôn validate header Origin để chống cross-site WebSocket hijacking.
WebTransport / HTTP/3 datagrams (hiện đại)
WebTransport là một API browser mới hơn được xây trên HTTP/3 (QUIC). Nó cung cấp nhiều stream độc lập, đúng thứ tự, tin cậy cùng với datagram không đảm bảo (unreliable) trên một kết nối duy nhất — mà không bị TCP head-of-line blocking (một packet chậm/mất trên một stream không làm nghẽn các stream khác). Datagram cho phép kiểu phân phối “fire and forget” lý tưởng cho state game hay media, nơi một update bị rớt còn tốt hơn một update bị trễ. Đây là kẻ kế nhiệm đang nổi cho các use case mà WebSockets bắt đầu đuối (nhiều stream song song, hoặc dữ liệu nhạy latency chấp nhận mất mát), dù hỗ trợ của browser và hạ tầng vẫn đang hoàn thiện — hãy coi nó là hướng tương lai, với WebSockets là mặc định của hiện tại.
Bảng so sánh
| Kỹ thuật | Hướng | Kết nối | Overhead đường truyền | Độ phức tạp | Phù hợp nhất cho |
|---|---|---|---|---|---|
| Short polling | Client kéo (pull) | Request mới mỗi lần poll | Cao (headers mỗi poll, gọi phí phạm) | Rất thấp | Kiểm tra trạng thái thưa, fallback đơn giản nhất |
| Long polling | Server→client (giả lập) | Giữ request, mở lại mỗi message | Trung bình (một round-trip mỗi message) | Thấp–trung bình | Gần real-time mà không cần protocol đặc biệt |
| SSE | Chỉ server→client | Một HTTP response sống lâu | Thấp (frame text, auto-reconnect) | Thấp | Dashboard, notification, feed, stream token |
| WebSockets | Full-duplex | Một kết nối TCP bền vững | Rất thấp (frame nhỏ) | Trung bình–cao | Chat, game, collaboration, app hai chiều |
| WebTransport (HTTP/3) | Full-duplex + datagram | Một kết nối QUIC, nhiều stream | Rất thấp, không HOL blocking | Cao (mới, ít hỗ trợ) | Dữ liệu throughput cao / lossy latency thấp |
Best Practices
Chọn đúng kỹ thuật
Bắt đầu từ yêu cầu, không phải từ công nghệ. Chỉ cần cập nhật server→client trên hạ tầng HTTP tiêu chuẩn? Hãy nghĩ tới SSE trước — nó đơn giản hơn và reconnect miễn phí. Cần trao đổi hai chiều độ trễ thấp (typing indicator, presence, con trỏ live)? Dùng WebSockets. Không giữ kết nối được (proxy hạn chế, serverless)? Long polling là fallback bền bỉ. Chỉ cần độ tươi thi thoảng? Short polling là ổn và rẻ nhất để vận hành. Nhiều hệ thống production xếp chồng các kỹ thuật này (WebSockets với fallback SSE hoặc long polling), đó chính xác là điều các thư viện như Socket.IO làm giúp bạn.
Scale hệ thống real-time
Đây là chỗ real-time trở nên khó. Một HTTP API stateless scale bằng cách thêm server sau một load balancer; một hệ thống real-time còn phải định tuyến message tới đúng server đang giữ kết nối mở của người nhận.
Sticky sessions. Kết nối của một client sống trên đúng một server cụ thể. Load balancer của bạn phải gửi traffic (và mọi lần reconnect) của client đó về đúng instance ban đầu — qua sticky sessions (session affinity theo cookie hoặc IP hash). Không có stickiness, một lần reconnect có thể rơi vào một server không biết client này, và luồng long-polling/handshake sẽ hỏng.
Pub/sub backplane (Redis). Khi các kết nối rải khắp nhiều server, server A phải phân phối một message tới một user đang kết nối với server B. Giải pháp chuẩn là một backplane: mọi server đăng ký một kênh pub/sub chung (thường là Redis Pub/Sub, hoặc một message broker — xem ./15-message-brokers-and-async.md). Khi bất kỳ server nào nhận được message, nó publish lên backplane; mỗi server sau đó đẩy tới các subscriber đang kết nối cục bộ với nó.
- 01client
- ws02WS server A
- publish03Redis Pub/Sub
- 04WS server B
- ws05client
// Fan-out across instances via Redis
import Redis from "ioredis";
const pub = new Redis(), sub = new Redis();
sub.subscribe("room:42");
sub.on("message", (_ch, payload) => {
// deliver to this server's local sockets in room 42
for (const ws of localSocketsInRoom("42")) ws.send(payload);
});
// when a message arrives on any server:
function onIncoming(msg) { pub.publish("room:42", JSON.stringify(msg)); }
Horizontal scaling & giới hạn kết nối. Hãy hoạch định capacity quanh số kết nối đồng thời (concurrent connections), không phải request/giây: tinh chỉnh giới hạn file descriptor của OS (ulimit -n), các thiết lập socket của kernel, và bộ nhớ mỗi process. Ưu tiên server async/event-driven (Node.js, Go, hoặc async Python/Java) không dành riêng một thread cho mỗi kết nối. Thêm instance theo chiều ngang phía sau backplane, và từ chối/loại bỏ kết nối một cách nhẹ nhàng khi một node bão hòa thay vì làm mọi người cùng chậm. Xem ./17-scalability-and-reliability.md cho load balancing, hoạch định capacity, và các pattern về reliability — tất cả đều áp dụng ở đây.
Reliability và tính đúng đắn
- Heartbeats. Gửi ping định kỳ (WebSocket ping/pong hoặc dòng comment của SSE
:\n\n) để phát hiện kết nối half-open và tránh bị proxy ngắt do idle. - Reconnect kèm backoff. SSE tự reconnect; với WebSockets, hãy tự implement exponential backoff kèm jitter, và resume state bằng một cursor/
Last-Event-IDđể không bỏ sót hay lặp event. - Thứ tự & phân phối message. TCP đảm bảo phân phối đúng thứ tự trong một kết nối, nhưng không xuyên qua reconnect hay nhiều server. Hãy gắn sequence ID và cho client yêu cầu resync khi có khoảng trống.
- Backpressure. Một client chậm có thể khiến server buffer không giới hạn. Theo dõi buffer gửi ra của từng kết nối và drop, gộp (coalesce), hoặc ngắt các consumer chậm.
- Security. Luôn dùng TLS (
wss:/// HTTPS). Xác thực kết nối (token trong handshake), phân quyền từng subscription/room, validate headerOrigin, và rate-limit message đầu vào.
Use case và pattern real-time
- Chat & messaging — WebSockets full-duplex; presence và typing indicator qua các event nhẹ; một broker/backplane để fan-out tới các room.
- Notification — thường là một chiều; SSE rất hợp và rẻ hơn WebSockets.
- Live dashboard & ticker — SSE hoặc WebSockets stream metric; gộp các update tần suất cao về một tick cố định để bảo vệ client.
- Collaborative editing — full-duplex kèm giải quyết xung đột (OT hoặc CRDT) đặt trên transport; latency thấp và đúng thứ tự là quan trọng nhất.
- Streaming response (ví dụ token của LLM) — SSE stream các token ngay khi chúng được sinh ra.
Về cách server phơi bày những thứ này qua HTTP và thương lượng protocol, xem ./06-apis.md; về việc phân phối event bền vững, tách rời (decoupled) phía sau lớp real-time, xem ./15-message-brokers-and-async.md.
Tài liệu tham khảo
Part of the Backend Roadmap knowledge base.
Overview
The classic web is built on HTTP request/response: the client asks, the server answers, and the connection is done. This model is client-initiated — the server can only speak when spoken to. That is perfect for loading a page or submitting a form, but it breaks down the moment the server has news the client didn’t ask for: a new chat message, a stock tick, a “your order shipped” notification, another user’s cursor moving in a shared document.
Real-time communication is the set of techniques that let the server push data to the client (and often the reverse, with low latency) so the UI reflects changes as they happen instead of on the next page refresh. There is no single “real-time protocol”; instead there is a spectrum of approaches, each trading off latency, overhead, complexity, and infrastructure cost:
- Short polling — the client repeatedly asks “anything new?” on a timer.
- Long polling — the client asks, and the server holds the request open until it has something to say.
- Server-Sent Events (SSE) — a single long-lived HTTP response streams events server→client.
- WebSockets — a persistent, full-duplex TCP connection for two-way messaging.
- WebTransport / HTTP/3 datagrams — a modern, multiplexed transport over QUIC.
This note explains how each works, when to reach for it, and — crucially — how to scale real-time systems across many servers, which is where most teams get surprised.
Why “real-time” is a spectrum, not a switch
“Real-time” in a backend context rarely means hard real-time (microsecond guarantees). It means low perceived latency — updates arriving in tens to hundreds of milliseconds. The right technique depends on:
- Direction — do you need server→client only, or full-duplex?
- Frequency — one event a minute, or thousands a second?
- Fan-out — one recipient, or a million subscribers?
- Delivery guarantees — is “best effort” fine, or do you need ordering and no loss?
- Infrastructure — can your proxies, load balancers, and CDNs hold connections open?
Fundamentals
The core problem: HTTP is client-initiated
In plain HTTP/1.1, a connection carries one request and one response (or several sequentially with keep-alive). The server has no channel to initiate a message. If a value changes on the server at time T, the client doesn’t find out until it makes its next request. Everything below is a strategy to shrink that gap — either by asking more often, by keeping a request open, or by upgrading to a genuinely bidirectional transport.
TCP, connections, and why they cost something
Every real-time technique except short polling relies on holding a connection open. A held connection consumes a file descriptor and memory on the server, a slot in every intermediary (load balancer, reverse proxy), and often a thread unless the server uses async/event-driven I/O. This is why real-time at scale is fundamentally a connection-management problem, not just a protocol choice — a single beefy server can serve millions of stateless HTTP requests per minute but may top out at tens of thousands of concurrent open WebSockets.
Latency vs overhead vs freshness
These three are in tension:
- Polling frequently → fresh data, but wasted requests and load when nothing changed.
- Polling rarely → cheap, but stale data.
- Holding connections → fresh and cheap on the wire, but expensive in held resources and harder to scale/operate.
Key Concepts
Short polling
The client asks on a fixed interval. Each poll is an independent HTTP request; the server answers immediately with either new data or “nothing yet”.
// Client: ask every 3 seconds
async function shortPoll() {
const res = await fetch("/api/messages?since=" + lastSeenId);
const data = await res.json();
if (data.length) render(data);
}
setInterval(shortPoll, 3000);
How it works: trivial — it’s just repeated GETs. Tradeoffs: dead simple and works everywhere (no special server support), but it wastes requests when there’s nothing new, and worst-case latency equals the poll interval. Doubling freshness means doubling request volume. Good for low-frequency, low-fan-out status checks; bad for chat.
Long polling
The client sends a request, and the server does not respond immediately. It holds the request open until it has data (or a timeout fires), then responds. The client immediately opens a new request. This gives near-real-time delivery over ordinary HTTP.
// Client: reconnect as soon as each response arrives
async function longPoll() {
try {
const res = await fetch("/api/messages/stream?since=" + lastSeenId);
const data = await res.json();
if (data.length) { render(data); lastSeenId = data.at(-1).id; }
} catch (e) {
await sleep(1000); // back off on error
}
longPoll(); // immediately re-establish
}
longPoll();
// Server (Express-ish): hold the response until an event or timeout
app.get("/api/messages/stream", async (req, res) => {
const since = Number(req.query.since);
const timeout = setTimeout(() => res.json([]), 25000); // avoid proxy idle-kill
emitter.once("message", (msg) => {
if (msg.id > since) { clearTimeout(timeout); res.json([msg]); }
});
});
Tradeoffs: much fresher than short polling and needs no special protocol, but each message still pays a full HTTP round-trip’s overhead (headers, reconnect), and holding requests open ties up server resources. It was the workhorse before WebSockets/SSE and remains a solid fallback.
Server-Sent Events (SSE)
SSE is a unidirectional server→client stream over a single long-lived HTTP response with Content-Type: text/event-stream. The browser’s built-in EventSource API handles the connection, parses events, and — a key feature — automatically reconnects and resumes using a Last-Event-ID header.
// Client: one line to subscribe
const es = new EventSource("/api/events");
es.onmessage = (e) => console.log("data:", e.data);
es.addEventListener("price", (e) => updateTicker(JSON.parse(e.data)));
es.onerror = () => {/* EventSource auto-reconnects; no code needed */};
// Server: stream text/event-stream frames
app.get("/api/events", (req, res) => {
res.set({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
const send = (event, data, id) => {
if (id) res.write(`id: ${id}\n`);
if (event) res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`); // blank line ends the event
};
const timer = setInterval(() => send("price", { sym: "AAPL", px: 199.9 }, Date.now()), 1000);
req.on("close", () => clearInterval(timer));
});
The wire format is plain text: lines like data:, event:, id:, and retry:, with a blank line terminating each event. Strengths: dead simple, runs over ordinary HTTP (works through most proxies and HTTP/2), built-in reconnect and event IDs, and text is easy to debug. Limits: server→client only (send client→server data via normal HTTP requests), text-only (no binary without encoding), and over HTTP/1.1 browsers cap connections per domain (~6) — a real problem largely solved by HTTP/2 multiplexing. Use cases: live dashboards, notifications, feeds, stock/price tickers, log streaming, AI token streaming.
WebSockets
WebSockets provide a persistent, full-duplex connection: after an initial HTTP handshake, both sides can send messages at any time over a single TCP connection. Defined by RFC 6455.
The upgrade handshake: the client sends an HTTP GET with Upgrade: websocket, Connection: Upgrade, and a random Sec-WebSocket-Key. If the server agrees, it replies 101 Switching Protocols with a Sec-WebSocket-Accept derived from that key (SHA-1 of the key + a fixed GUID, base64-encoded). After the 101, the bytes on the wire are no longer HTTP — they are WebSocket frames.
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Frames: data travels in small framed messages carrying an opcode (text, binary, ping, pong, close), a payload length, and — from client to server — a masking key. Control frames (ping/pong) keep the connection alive and detect dead peers.
// Server: Node.js with the "ws" library
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws) => {
ws.send("welcome");
ws.on("message", (buf) => {
const msg = buf.toString();
// broadcast to everyone
for (const client of wss.clients) {
if (client.readyState === client.OPEN) client.send(msg);
}
});
});
// Client: browser WebSocket API
const ws = new WebSocket("wss://example.com/chat");
ws.onopen = () => ws.send("hello");
ws.onmessage = (e) => console.log("got:", e.data);
ws.onclose = () => console.log("closed"); // no auto-reconnect — you implement it
When to use: genuine two-way, low-latency interaction — chat, multiplayer games, collaborative editing, live trading, IoT control. Watch out for: no built-in reconnect (unlike SSE, you write your own backoff), you must send periodic pings/heartbeats to detect broken connections and defeat idle timeouts, use wss:// (TLS) in production, and always validate the Origin header to prevent cross-site WebSocket hijacking.
WebTransport / HTTP/3 datagrams (modern)
WebTransport is a newer browser API built on HTTP/3 (QUIC). It offers multiple independent, ordered, reliable streams and unreliable datagrams over a single connection — without TCP head-of-line blocking (a slow/lost packet on one stream doesn’t stall the others). Datagrams enable “fire and forget” delivery ideal for game state or media where a dropped update is better than a delayed one. It’s the emerging successor for use cases that stretch WebSockets (many parallel streams, or latency-sensitive lossy data), though browser and infrastructure support is still maturing — treat it as forward-looking, with WebSockets as today’s default.
Comparison table
| Technique | Direction | Connection | Wire overhead | Complexity | Best for |
|---|---|---|---|---|---|
| Short polling | Client-initiated pull | New request each poll | High (headers per poll, wasted calls) | Very low | Infrequent status checks, simplest fallback |
| Long polling | Server→client (emulated) | Held request, reopened per message | Medium (round-trip per message) | Low–medium | Near-real-time without special protocol |
| SSE | Server→client only | One long-lived HTTP response | Low (text frames, auto-reconnect) | Low | Dashboards, notifications, feeds, token streaming |
| WebSockets | Full-duplex | One persistent TCP connection | Very low (small frames) | Medium–high | Chat, games, collaboration, two-way apps |
| WebTransport (HTTP/3) | Full-duplex + datagrams | One QUIC connection, many streams | Very low, no HOL blocking | High (new, less support) | High-throughput / lossy low-latency data |
Best Practices
Choosing the right technique
Start from the requirement, not the technology. Need only server→client updates over standard HTTP infrastructure? Reach for SSE first — it’s simpler and reconnects for free. Need two-way low-latency messaging (typing indicators, presence, live cursors)? Use WebSockets. Can’t hold connections at all (restrictive proxies, serverless)? Long polling is a robust fallback. Only need occasional freshness? Short polling is fine and cheapest to operate. Many production systems layer these (WebSockets with an SSE or long-polling fallback), which is exactly what libraries like Socket.IO do for you.
Scaling real-time systems
This is where real-time gets hard. A stateless HTTP API scales by adding servers behind a load balancer; a real-time system also has to route a message to whichever server holds the recipient’s open connection.
Sticky sessions. A given client’s connection lives on one specific server. Your load balancer must send that client’s traffic (and any reconnects) back to the same instance — via sticky sessions (session affinity by cookie or IP hash). Without stickiness, a reconnect can land on a server that doesn’t know the client, and long-polling/handshake flows break.
Pub/sub backplane (Redis). With connections spread across many servers, server A must deliver a message to a user connected to server B. The standard solution is a backplane: every server subscribes to a shared pub/sub channel (commonly Redis Pub/Sub, or a message broker — see ./15-message-brokers-and-async.md). When any server receives a message, it publishes to the backplane; every server then pushes to its own locally-connected subscribers.
- 01client
- ws02WS server A
- publish03Redis Pub/Sub
- 04WS server B
- ws05client
// Fan-out across instances via Redis
import Redis from "ioredis";
const pub = new Redis(), sub = new Redis();
sub.subscribe("room:42");
sub.on("message", (_ch, payload) => {
// deliver to this server's local sockets in room 42
for (const ws of localSocketsInRoom("42")) ws.send(payload);
});
// when a message arrives on any server:
function onIncoming(msg) { pub.publish("room:42", JSON.stringify(msg)); }
Horizontal scaling & connection limits. Plan capacity around concurrent connections, not requests/sec: tune OS file-descriptor limits (ulimit -n), kernel socket settings, and per-process memory. Prefer async/event-driven servers (Node.js, Go, or async Python/Java) that don’t dedicate a thread per connection. Add instances horizontally behind the backplane, and shed or reject connections gracefully when a node is saturated rather than degrading everyone. See ./17-scalability-and-reliability.md for load balancing, capacity planning, and reliability patterns that all apply here.
Reliability and correctness
- Heartbeats. Send periodic pings (WebSocket ping/pong or SSE comment lines
:\n\n) to detect half-open connections and keep proxies from idling you out. - Reconnect with backoff. SSE reconnects itself; for WebSockets, implement exponential backoff with jitter, and resume state using a cursor/
Last-Event-IDso you don’t miss or duplicate events. - Message ordering & delivery. TCP guarantees in-order delivery within one connection, but not across reconnects or servers. Attach sequence IDs and let clients request a resync gap.
- Backpressure. A slow client can make the server buffer unboundedly. Track each connection’s outgoing buffer and drop, coalesce, or disconnect slow consumers.
- Security. Always use TLS (
wss:/// HTTPS). Authenticate the connection (token in the handshake), authorize each subscription/room, validate theOriginheader, and rate-limit inbound messages.
Real-time use cases and patterns
- Chat & messaging — full-duplex WebSockets; presence and typing indicators via lightweight events; a broker/backplane for fan-out to rooms.
- Notifications — usually one-directional; SSE is an excellent fit and cheaper than WebSockets.
- Live dashboards & tickers — SSE or WebSockets streaming metrics; coalesce high-frequency updates to a fixed tick to protect clients.
- Collaborative editing — full-duplex with conflict resolution (OT or CRDTs) layered on top of the transport; low latency and ordering matter most.
- Streaming responses (e.g., LLM tokens) — SSE streams tokens as they’re generated.
For how the server exposes these over HTTP and negotiates protocols, see ./06-apis.md; for durable, decoupled event delivery behind the real-time layer, see ./15-message-brokers-and-async.md.