System Design · Real-Time Systems

WebSockets vs Server-Sent Events vs Long Polling: The Real-Time Communication Guide

What actually goes over the wire with each technique, working server and client code, how to scale a million persistent connections, and a clear rule for choosing.

✍️ Pranjal Jain, Ex-Microsoft · IIT Kanpur 📅 10 October 2026 ⏱ 16 min read
Illustration of three real-time communication channels between client and server: polling, one-way stream and two-way connection
TL;DR
  • 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 via Last-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

  1. Client sends GET /updates?since=1042.
  2. If there is newer data, the server responds immediately.
  3. If not, the server holds the request open (up to a timeout, say 25–30 seconds).
  4. When an event arrives, the server responds with it. On timeout, it responds with an empty result.
  5. The client immediately sends the next request with the latest cursor.
Client Server │── GET /updates?since=1042 ──────────────►│ (no data yet; hold) │ │ ... 8 s pass ... │◄────────── 200 [{id:1043, ...}] ─────────│ (event arrived) │── GET /updates?since=1043 ──────────────►│ (hold again) │ │ ... 30 s, nothing ... │◄────────── 200 [] (timeout) ─────────────│ │── GET /updates?since=1043 ──────────────►│
Python (FastAPI long-polling endpoint)
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 (multiple data: 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 sends Last-Event-ID: 102 so 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)

JavaScript (Node.js)
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

JavaScript (browser)
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 fetch POSTs, 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 EventSource cannot set custom headers (such as Authorization); 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)

JavaScript (Node.js, ws)
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

JavaScript (browser)
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 pollingSSEWebSockets
DirectionServer → client (client sends via normal requests)Server → clientBoth directions
TransportRepeated HTTP requestsOne long HTTP responseUpgraded TCP connection with framing
Data typesAnything HTTP carriesUTF-8 textText and binary
Per-message overheadFull HTTP request + responseA few bytes of field names2–14 byte frame header
LatencyLow, plus a re-request gapLowLowest
Auto-reconnect / resumeInherent (each poll is new); resume via cursorBuilt in (retry, Last-Event-ID)Build it yourself
Infra compatibilityUniversalVery good; watch proxy bufferingNeeds Upgrade support and long idle timeouts
HTTP/2 multiplexingYesYesOnly via RFC 8441 extended CONNECT where supported; usually its own TCP connection
Typical usesFallbacks, infrequent updatesFeeds, notifications, live scores, progress, LLM streamingChat, 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

Clients (mobile / web) │ wss:// ▼ ┌──────────────────┐ L4/L7 LB with long idle timeout, │ Load balancer │ least-connections balancing └──┬──────┬──────┬─┘ ▼ ▼ ▼ ┌──────┐┌──────┐┌──────┐ Gateway nodes: hold sockets, auth, │ GW-1 ││ GW-2 ││ GW-3 │ heartbeats. No business logic. └──┬───┘└──┬───┘└──┬───┘ │ subscribe user:/room: channels ▼ ▼ ▼ ┌────────────────────────┐ ┌──────────────────────┐ │ Pub/Sub (Redis/NATS/ │◄─────│ Backend services │ │ Kafka) + presence store│ pub │ (chat, orders, ...) │ └────────────────────────┘ └──────────────────────┘

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.

JavaScript (Node.js, ioredis)
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.1 and explicit Upgrade/Connection headers for WebSockets.
  • Buffering: for SSE, disable response buffering (proxy_buffering off or X-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.

  1. Give every message a monotonically increasing ID per stream (per conversation, per user feed).
  2. Persist before you push. A message is "sent" when it is durably stored, not when it is written to a socket.
  3. Client tracks the last ID it processed. On reconnect, it sends that ID (SSE does this automatically with Last-Event-ID).
  4. Server replays everything after that ID, then continues live.
  5. Client dedupes by ID, since replay and live delivery can overlap.
  6. Acks for important messages. Chat apps show single and double ticks: stored on server, delivered to device, read.

Which One Should You Pick?

Use casePickWhy
Live cricket score, stock ticker on a web pageSSEOne-way, text, auto-resume, HTTP-friendly
Notifications bell in a web appSSE (or long polling)Infrequent, one-way
LLM response streamingSSEOne-way token stream per request
Food delivery / cab tracking on the customer sideSSE or WebSocketMostly server → client location updates (see Uber/Ola design)
Driver app sending GPS every few secondsWebSocket (or batched HTTP)Frequent client → server messages
Chat with typing indicators and presenceWebSocketBidirectional, frequent, low latency
Collaborative editorWebSocketMany small ops both ways (see Google Docs design)
Multiplayer game, trading terminalWebSocket (or WebTransport)Lowest latency, binary
Corporate networks that block upgradesLong polling fallbackWorks everywhere
💡
Simple ruleIf the client mostly listens, use SSE and send the occasional client action as a normal POST. Reach for WebSockets when the client talks frequently too. Keep long polling as the fallback, not the plan.

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

  1. "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.
  2. "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.
  3. "What happens when a gateway crashes?" Clients reconnect with jittered backoff to another node, resume from last message ID; messages were persisted before push.
  4. "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.
  5. "How many gateway servers do you need?" Peak concurrent connections divided by tested capacity per node, plus headroom.
  6. "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

What is the difference between WebSockets and Server-Sent Events?▾
WebSockets provide a full-duplex connection where client and server can both send messages at any time, including binary data, over a protocol upgraded from HTTP. Server-Sent Events are a one-way stream of text events from server to client over a normal HTTP response, with automatic reconnection and resume built into the browser's EventSource API. Use SSE when only the server pushes; use WebSockets when the client also sends frequent messages.
Is long polling still used?▾
Yes, as a fallback when WebSockets or streaming responses are blocked by proxies, and in systems where updates are infrequent and simplicity matters. Libraries such as Socket.IO start or fall back to HTTP long polling. Its costs are a new request per message and extra latency between a response and the next request.
Do WebSockets need sticky sessions?▾
A single WebSocket connection stays on the server it connected to, so it does not need stickiness once established. Stickiness matters when a protocol uses several HTTP requests that must reach the same server, such as Socket.IO's long polling handshake, or when per-user state lives only in one server's memory. The usual design keeps servers stateless and uses a pub/sub layer to route messages to whichever server holds the connection.
How many WebSocket connections can one server handle?▾
It depends on memory per connection, message rate and the runtime, not on a fixed protocol limit. Idle connections can be cheap enough that a single well-tuned server holds hundreds of thousands, but real limits come from file descriptor limits, kernel buffer memory, TLS overhead and message fan-out. Load test with realistic message rates before sizing a fleet.
Does SSE work with HTTP/2?▾
Yes, and it works better. Over HTTP/1.1 browsers limit the number of concurrent connections per domain to about six, so several SSE tabs can exhaust it. Over HTTP/2 many streams are multiplexed over one connection, so that limit effectively disappears.
Which is better for a chat application?▾
WebSockets are the usual choice for chat because clients send messages and typing indicators frequently and need low latency in both directions. SSE plus regular HTTP POSTs for sending also works well and is simpler to run behind standard HTTP infrastructure. Long polling is a fallback, not a first choice.
How do you send a message to a user connected to a different WebSocket server?▾
Keep a pub/sub channel or a connection registry. Each gateway server subscribes to channels for the users or rooms it currently holds connections for, often in Redis Pub/Sub, Redis Streams, Kafka or NATS. A backend service publishes the message to the user's channel, and whichever gateway holds that user's socket forwards it.

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 →
Pranjal Jain
Pranjal Jain

Ex-Microsoft SDE · IIT Kanpur · Founder of Prepflix. Helps engineers crack startup and product company interviews across India.