← Backend← Backend
BackendBackend19 Th7, 2026Jul 19, 202615 phút đọc13 min read

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:

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:

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:

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ậtHướngKết nốiOverhead đường truyềnĐộ phức tạpPhù hợp nhất cho
Short pollingClient kéo (pull)Request mới mỗi lần pollCao (headers mỗi poll, gọi phí phạm)Rất thấpKiểm tra trạng thái thưa, fallback đơn giản nhất
Long pollingServer→client (giả lập)Giữ request, mở lại mỗi messageTrung bình (một round-trip mỗi message)Thấp–trung bìnhGần real-time mà không cần protocol đặc biệt
SSEChỉ server→clientMột HTTP response sống lâuThấp (frame text, auto-reconnect)ThấpDashboard, notification, feed, stream token
WebSocketsFull-duplexMột kết nối TCP bền vữngRất thấp (frame nhỏ)Trung bình–caoChat, game, collaboration, app hai chiều
WebTransport (HTTP/3)Full-duplex + datagramMột kết nối QUIC, nhiều streamRất thấp, không HOL blockingCao (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ó.

Redis Pub/Sub across WebSocket servers
  1. 01client
  2. ws
    02WS server A
  3. publish
    03Redis Pub/Sub
  4. 04WS server B
  5. ws
    05client
// 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

Use case và pattern real-time

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:

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:

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:

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

TechniqueDirectionConnectionWire overheadComplexityBest for
Short pollingClient-initiated pullNew request each pollHigh (headers per poll, wasted calls)Very lowInfrequent status checks, simplest fallback
Long pollingServer→client (emulated)Held request, reopened per messageMedium (round-trip per message)Low–mediumNear-real-time without special protocol
SSEServer→client onlyOne long-lived HTTP responseLow (text frames, auto-reconnect)LowDashboards, notifications, feeds, token streaming
WebSocketsFull-duplexOne persistent TCP connectionVery low (small frames)Medium–highChat, games, collaboration, two-way apps
WebTransport (HTTP/3)Full-duplex + datagramsOne QUIC connection, many streamsVery low, no HOL blockingHigh (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.

Redis Pub/Sub across WebSocket servers
  1. 01client
  2. ws
    02WS server A
  3. publish
    03Redis Pub/Sub
  4. 04WS server B
  5. ws
    05client
// 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

Real-time use cases and patterns

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.

References