- Long polling: client asks, server holds the request until there is data, responds, client immediately asks again. Works everywhere; one HTTP request per batch of messages.
- Server-Sent Events (SSE): one long-lived HTTP response streaming
text/event-stream. Server → client only, text only, with built-in auto-reconnect and resume viaLast-Event-ID. - WebSockets: an HTTP request upgraded (101 Switching Protocols) to a full-duplex, framed, low-overhead connection. Text or binary, both directions.
- Pick SSE for feeds, notifications, live scores, LLM token streaming. Pick WebSockets for chat, multiplayer, collaborative editing, trading. Use long polling as a fallback.
- Scaling persistent connections = stateless gateway servers + pub/sub (Redis, Kafka, NATS) to route messages + heartbeats + jittered reconnects + graceful draining.
HTTP was designed for the client to ask and the server to answer. Real-time features flip that: the server learns something first (a new message, a driver moved, a wicket fell) and needs to tell the client now. Three techniques dominate, and choosing between them changes your infrastructure, your load balancer config, and how you scale. This guide shows what each looks like on the wire, gives runnable code, and explains how teams scale to millions of open connections.
The Problem: HTTP Is Request-Response
In plain HTTP the server cannot speak until spoken to. If a delivery app wants to show the rider moving, the naive approach is for the client to ask "any update?" every second. With 1 million users watching orders, that is 1 million requests per second, most of which return "no change". Each request carries several hundred bytes to a few KB of headers (cookies, auth tokens, user agent) and costs TLS and routing work at every layer.
Every real-time technique is a way to reduce that waste: hold the request open until there is something to say (long polling), keep a response open and stream into it (SSE), or switch to a different protocol where either side can speak at any time (WebSockets).
Short Polling and Long Polling
Short polling
The client sends a request every N seconds. Simple and cacheable, but latency averages N/2 and most requests are wasted. Acceptable for dashboards refreshed every 30–60 seconds; poor for anything interactive.
Long polling
- Client sends
GET /updates?since=1042. - If there is newer data, the server responds immediately.
- If not, the server holds the request open (up to a timeout, say 25–30 seconds).
- When an event arrives, the server responds with it. On timeout, it responds with an empty result.
- The client immediately sends the next request with the latest cursor.
import asyncio
from fastapi import FastAPI
app = FastAPI()
events: list[dict] = [] # in production: Redis Stream / DB table
new_event = asyncio.Condition()
@app.post("/publish")
async def publish(msg: dict):
async with new_event:
events.append({"id": len(events) + 1, **msg})
new_event.notify_all()
return {"ok": True}
@app.get("/updates")
async def updates(since: int = 0, timeout: float = 25.0):
async with new_event:
try:
await asyncio.wait_for(
new_event.wait_for(lambda: len(events) > since), timeout)
except asyncio.TimeoutError:
return [] # client re-polls immediately
return events[since:]
The cursor (since) is essential: messages that arrive between one response and the next request are not lost, because the next request asks for everything after the last ID it saw.
Costs: a full HTTP request and response per delivery (headers each time), a small latency gap between response and the next request, and many held requests consuming server resources. Under bursty traffic, clients effectively poll in a tight loop. Strengths: works through essentially any proxy or firewall that allows HTTP, and needs no special server support.
Server-Sent Events (SSE): One-Way Streaming over HTTP
SSE, standardised in the WHATWG HTML Living Standard, uses one ordinary HTTP response that never ends. The server sets Content-Type: text/event-stream and writes events as lines of text, flushing after each.
The wire format
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
retry: 5000
id: 101
event: score
data: {"match":"IND-AUS","runs":187,"wickets":3,"over":"32.4"}
id: 102
event: wicket
data: {"batter":"R Sharma","how":"c Smith b Starc"}
: heartbeat comment line, ignored by the client
- Each event ends with a blank line.
data:lines carry the payload (multipledata:lines are joined with newlines). event:names the event type; the client can listen per type.id:sets the last event ID. On reconnect, the browser automatically sendsLast-Event-ID: 102so the server can resume.retry:tells the browser how many milliseconds to wait before reconnecting.- Lines starting with
:are comments, commonly used as heartbeats to stop proxies from closing idle connections.
Server (Node.js, no framework)
const http = require("http");
const clients = new Set();
let lastId = 0;
const history = []; // recent events for resume; bounded
function broadcast(type, payload) {
const evt = { id: ++lastId, type, data: JSON.stringify(payload) };
history.push(evt);
if (history.length > 1000) history.shift();
for (const res of clients) write(res, evt);
}
function write(res, { id, type, data }) {
res.write(`id: ${id}\nevent: ${type}\ndata: ${data}\n\n`);
}
http.createServer((req, res) => {
if (req.url !== "/stream") { res.writeHead(404).end(); return; }
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", // disable nginx response buffering
});
res.write("retry: 3000\n\n");
// resume: replay anything the client missed
const since = Number(req.headers["last-event-id"] || 0);
for (const evt of history) if (evt.id > since) write(res, evt);
clients.add(res);
const hb = setInterval(() => res.write(": hb\n\n"), 15000);
req.on("close", () => { clearInterval(hb); clients.delete(res); });
}).listen(8080);
setInterval(() => broadcast("score", { runs: Math.floor(Math.random() * 300) }), 2000);
Client
const es = new EventSource("/stream"); // same-origin; cookies sent automatically
es.addEventListener("score", (e) => {
const s = JSON.parse(e.data);
document.querySelector("#score").textContent = s.runs;
});
es.onerror = () => {
// browser reconnects automatically using the retry value and Last-Event-ID
console.warn("stream interrupted, reconnecting...");
};
SSE strengths and limits
- Plain HTTP: works with existing load balancers, auth cookies, HTTP/2, CDNs that support streaming, and standard observability.
- Automatic reconnect and resume come free in the browser.
- Server → client only. The client sends data with normal
fetchPOSTs, which is often fine. - Text only (UTF-8). Binary must be base64-encoded.
- HTTP/1.1 connection limit: browsers allow about six connections per origin, so many tabs with SSE can starve other requests. HTTP/2 multiplexing removes this concern.
- The native
EventSourcecannot set custom headers (such asAuthorization); use cookies, a short-lived token in the URL, or a fetch-based SSE client library.
SSE is also how most LLM APIs stream tokens to clients; see ChatGPT system design.
WebSockets: Full-Duplex over a Single Connection
WebSocket (RFC 6455) starts as an HTTP/1.1 request asking to switch protocols. After the server agrees, the TCP connection carries WebSocket frames in both directions until either side closes it.
The handshake
GET /chat HTTP/1.1
Host: chat.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
The server proves it understood the WebSocket protocol by returning Sec-WebSocket-Accept = base64(SHA-1(key + a fixed GUID)). The example key and accept value above are the ones used in the RFC itself.
Frames
After the handshake, messages are sent as frames with a small header (2 bytes for payloads under 126 bytes, up to 14 bytes in general, including the 4-byte mask on client-to-server frames). Frame types include text, binary, ping, pong and close. Compare that with hundreds of bytes of HTTP headers per long-poll response. Client-to-server frames are masked with a random key, a defence against cache-poisoning attacks on intermediaries, not a security feature for your data; use wss:// (TLS) for that.
Server (Node.js with the ws library)
const { WebSocketServer } = require("ws");
const wss = new WebSocketServer({ port: 8081 });
const rooms = new Map(); // roomId -> Set<WebSocket>
wss.on("connection", (ws, req) => {
// authenticate here: verify a token from the query string or cookie
ws.isAlive = true;
ws.on("pong", () => (ws.isAlive = true));
ws.on("message", (raw) => {
let msg;
try { msg = JSON.parse(raw); } catch { return ws.close(1003, "bad json"); }
if (msg.type === "join") {
if (!rooms.has(msg.room)) rooms.set(msg.room, new Set());
rooms.get(msg.room).add(ws);
ws.room = msg.room;
} else if (msg.type === "say" && ws.room) {
const out = JSON.stringify({ type: "said", text: msg.text, at: Date.now() });
for (const peer of rooms.get(ws.room)) {
if (peer.readyState === peer.OPEN) peer.send(out);
}
}
});
ws.on("close", () => ws.room && rooms.get(ws.room)?.delete(ws));
});
// heartbeat: drop connections that stopped answering pings
setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) { ws.terminate(); continue; }
ws.isAlive = false;
ws.ping();
}
}, 30000);
Client
function connect(room, attempt = 0) {
const ws = new WebSocket("wss://chat.example.com/chat?token=" + getToken());
ws.onopen = () => { attempt = 0; ws.send(JSON.stringify({ type: "join", room })); };
ws.onmessage = (e) => render(JSON.parse(e.data));
ws.onclose = () => {
// exponential backoff with full jitter, capped at 30 s
const delay = Math.random() * Math.min(30000, 1000 * 2 ** attempt);
setTimeout(() => connect(room, attempt + 1), delay);
};
return ws;
}
Unlike SSE, the browser does not reconnect a WebSocket automatically, and there is no built-in resume. You implement both, which is a big reason libraries like Socket.IO exist.
WebSockets vs SSE vs Long Polling: Side-by-Side
| Long polling | SSE | WebSockets | |
|---|---|---|---|
| Direction | Server → client (client sends via normal requests) | Server → client | Both directions |
| Transport | Repeated HTTP requests | One long HTTP response | Upgraded TCP connection with framing |
| Data types | Anything HTTP carries | UTF-8 text | Text and binary |
| Per-message overhead | Full HTTP request + response | A few bytes of field names | 2–14 byte frame header |
| Latency | Low, plus a re-request gap | Low | Lowest |
| Auto-reconnect / resume | Inherent (each poll is new); resume via cursor | Built in (retry, Last-Event-ID) | Build it yourself |
| Infra compatibility | Universal | Very good; watch proxy buffering | Needs Upgrade support and long idle timeouts |
| HTTP/2 multiplexing | Yes | Yes | Only via RFC 8441 extended CONNECT where supported; usually its own TCP connection |
| Typical uses | Fallbacks, infrequent updates | Feeds, notifications, live scores, progress, LLM streaming | Chat, games, collaborative editing, trading terminals |
Two other options worth knowing: WebTransport (over HTTP/3/QUIC; multiple streams and unreliable datagrams, useful for games and media, with growing but not universal browser support) and WebRTC data channels (peer-to-peer, used for calls, see Zoom system design). For native mobile apps, push notifications (FCM/APNs) handle the "app is in background" case that none of these cover.
Scaling WebSockets (and SSE) to Millions of Connections
Persistent connections change the scaling model. A stateless REST server handles a request and forgets it; a WebSocket server holds a connection for minutes or hours, and messages for a user must reach the one server that holds that user's socket.
Reference architecture
1. Separate connection handling from business logic
Gateway servers do only connection work: TLS, auth on connect, heartbeats, subscribe/unsubscribe, forward messages. Business logic lives in normal stateless services. This lets you deploy business logic without dropping millions of connections, and scale each tier independently.
2. Route messages with pub/sub
When user U connects to GW-2, GW-2 subscribes to channel user:U (and any room channels). To deliver a message, a backend service publishes to user:U; only GW-2 receives it and writes it to the socket.
const Redis = require("ioredis");
const sub = new Redis(); // subscriber connection (dedicated)
const local = new Map(); // userId -> Set<WebSocket> on THIS gateway
function onConnect(userId, ws) {
if (!local.has(userId)) {
local.set(userId, new Set());
sub.subscribe(`user:${userId}`);
}
local.get(userId).add(ws);
ws.on("close", () => {
const set = local.get(userId);
set.delete(ws);
if (set.size === 0) { local.delete(userId); sub.unsubscribe(`user:${userId}`); }
});
}
sub.on("message", (channel, payload) => {
const userId = channel.slice(5);
for (const ws of local.get(userId) || []) ws.send(payload);
});
// any backend service, anywhere:
// await new Redis().publish(`user:${userId}`, JSON.stringify(msg));
Redis Pub/Sub is fire-and-forget: if the gateway is down at that moment, the message is gone. For guaranteed delivery, persist messages first (database, Redis Streams or Kafka) and use pub/sub only as a "wake up and deliver" signal, with the client fetching missed messages by cursor on reconnect. At very high fan-out, alternatives include NATS, Kafka partitioned by user, or a connection registry (user → gateway in Redis) with direct gateway-to-gateway RPC. See the Redis deep dive for Pub/Sub vs Streams.
3. Sticky sessions: when you need them
A single WebSocket connection is pinned to one server for its lifetime by nature; the load balancer does not need stickiness for it. Stickiness matters when:
- A protocol uses several HTTP requests per session, like Socket.IO's polling handshake before upgrading. Socket.IO's docs require sticky sessions when polling is enabled across multiple nodes.
- Long polling clients must return to the server that holds their buffered messages (better: store buffers in shared storage).
The robust design avoids server-local state so that any gateway can serve any reconnect.
4. Load balancer and proxy settings
- Idle timeouts: many proxies and cloud load balancers close idle connections after a default (AWS ALB's default idle timeout is 60 seconds). Send heartbeats more often than the shortest timeout in the path, or raise the timeout.
- Upgrade headers: nginx needs
proxy_http_version 1.1and explicitUpgrade/Connectionheaders for WebSockets. - Buffering: for SSE, disable response buffering (
proxy_buffering offorX-Accel-Buffering: no), or events will arrive in bursts. - Balancing algorithm: least-connections works better than round robin for long-lived connections.
5. Capacity per gateway
Limits are set by memory per connection (application buffers, TLS state, kernel socket buffers), file descriptors (raise ulimit -n), and CPU for message fan-out and TLS. A rough planning method: measure memory per idle connection and CPU per 1,000 messages/second in a load test, then size for peak concurrent connections with headroom. For example, if a test shows ~20 KB per connection, 200,000 connections need ~4 GB just for connection state. (Measure your own; the number varies widely by runtime and settings.)
6. Deploys and the reconnect storm
Restarting a gateway drops all its connections at once, and they all reconnect at once. Mitigations:
- Graceful drain: stop accepting new connections, then close existing ones gradually over minutes, ideally with a "reconnect" message so clients move elsewhere.
- Jittered backoff on clients (as in the client code above).
- Rate-limit connection attempts and make auth on connect cheap (validate a JWT locally rather than calling an auth service per connect).
Reconnects, Ordering and Delivery Guarantees
Mobile networks drop connections constantly: entering a lift, switching from Wi-Fi to 4G. The transport is not your delivery guarantee; your protocol on top of it is.
- Give every message a monotonically increasing ID per stream (per conversation, per user feed).
- Persist before you push. A message is "sent" when it is durably stored, not when it is written to a socket.
- Client tracks the last ID it processed. On reconnect, it sends that ID (SSE does this automatically with
Last-Event-ID). - Server replays everything after that ID, then continues live.
- Client dedupes by ID, since replay and live delivery can overlap.
- Acks for important messages. Chat apps show single and double ticks: stored on server, delivered to device, read.
Which One Should You Pick?
| Use case | Pick | Why |
|---|---|---|
| Live cricket score, stock ticker on a web page | SSE | One-way, text, auto-resume, HTTP-friendly |
| Notifications bell in a web app | SSE (or long polling) | Infrequent, one-way |
| LLM response streaming | SSE | One-way token stream per request |
| Food delivery / cab tracking on the customer side | SSE or WebSocket | Mostly server → client location updates (see Uber/Ola design) |
| Driver app sending GPS every few seconds | WebSocket (or batched HTTP) | Frequent client → server messages |
| Chat with typing indicators and presence | WebSocket | Bidirectional, frequent, low latency |
| Collaborative editor | WebSocket | Many small ops both ways (see Google Docs design) |
| Multiplayer game, trading terminal | WebSocket (or WebTransport) | Lowest latency, binary |
| Corporate networks that block upgrades | Long polling fallback | Works everywhere |
How This Comes Up in System Design Interviews
Real-time transport is a sub-decision in chat (WhatsApp), ride-hailing, food delivery tracking, live leaderboards (real-time leaderboard design), collaborative editing, notifications, and live streaming comments. Interviewers want to see you pick deliberately and then handle scale.
What interviewers probe
- "Why WebSockets and not polling?" Quantify: 10M users polling every 2 s is 5M requests/s of mostly empty responses versus one open connection each.
- "User A is on server 1, user B is on server 7. How does A's message reach B?" Pub/sub channel per user or a connection registry.
- "What happens when a gateway crashes?" Clients reconnect with jittered backoff to another node, resume from last message ID; messages were persisted before push.
- "How do you know who is online?" Presence: heartbeat updates a key with TTL in Redis; offline when it expires; fan out presence changes only to interested contacts.
- "How many gateway servers do you need?" Peak concurrent connections divided by tested capacity per node, plus headroom.
- "How do you deploy without disconnecting everyone?" Graceful drain, rolling deploys, separate gateway tier from business logic.
Common mistakes
- Choosing WebSockets for a one-way feed and then rebuilding reconnect and resume that SSE gives for free.
- Assuming a message written to a socket was delivered.
- Keeping user-to-connection state only in one server's memory with no plan for routing across servers.
- Ignoring load balancer idle timeouts and wondering why connections drop every 60 seconds.
- No backoff with jitter, so a deploy triggers a self-inflicted DDoS.
For the networking fundamentals underneath (TCP, TLS, HTTP/2 multiplexing), see computer networks interview questions.
Frequently Asked Questions
Design Real-Time Systems with Confidence
Prepflix's system design course covers chat, live tracking, collaborative editing and notifications, including how to scale persistent connections.
Explore the Prepflix Course →