All articles
APIsRealtimeSystem Design13 min read

Realtime APIs: WebSockets vs SSE vs Long Polling vs Webhooks

Four ways a server can push to a client, compared on protocol, reconnection, scaling, proxy behaviour and cost — with fan-out architecture, backpressure and webhook delivery guarantees.

HTTP's missing half

HTTP is client-initiated. The server cannot speak first. Everything realtime on the web is a workaround for that one limitation, and the four workarounds differ in how honest they are about it.

Polling and long polling

Short polling asks every N seconds. Simple, stateless, and mostly wasted requests — 99% return “nothing new”. Fine for a dashboard refreshing every 30 seconds; ruinous at scale.

Long polling is smarter: the server holds the request open until it has something to say or a timeout expires, then the client immediately reconnects. You get near-realtime delivery over completely ordinary HTTP.

async function listen(since) {
  while (true) {
    const res = await fetch(`/api/events?since=${since}&wait=25`);
    if (res.status === 204) continue;              // timeout, no events
    const { events, cursor } = await res.json();
    since = cursor;                                 // resume exactly where we left off
    events.forEach(handle);
  }
}
Long polling with a cursor so nothing is missed between reconnects.
  • Works through every proxy, firewall and corporate network on earth.
  • Costs a connection slot per client anyway, plus request overhead per message.
  • There is always a reconnect window — the cursor is what stops it dropping events.

Server-Sent Events

SSE is a standardised long-lived HTTP response with a tiny text format. The server keeps writing; the browser's EventSource parses events and reconnects automatically, replaying the last event id so the server can resume.

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

id: 1042
event: order.status
data: {"id":"8812","status":"SHIPPED"}

: heartbeat comment to keep proxies from closing us

id: 1043
event: order.status
data: {"id":"8812","status":"DELIVERED"}
The entire SSE wire format.
const es = new EventSource("/api/orders/8812/stream");
es.addEventListener("order.status", e => render(JSON.parse(e.data)));
es.onerror = () => {/* the browser retries on its own, with Last-Event-ID */};
The client is three lines.
  • Free reconnection with resume — the browser resends Last-Event-ID. This alone saves a week of work versus WebSockets.
  • Plain HTTP, so auth headers, cookies, compression and observability all just work.
  • Text only, and one-directional. Under HTTP/1.1 browsers cap ~6 connections per origin; under HTTP/2 that limit disappears.
  • Send a comment heartbeat every 15–30s or intermediaries will close idle streams.
SSE is the right default for streaming
Notification bells, live dashboards, job progress, order tracking, and LLM token streaming are all one-directional. If the client only ever needs to receive, SSE gives you the whole thing over infrastructure you already run.

WebSockets

A WebSocket starts as an HTTP request with Upgrade: websocket, gets a 101 response, and then the TCP connection stops being HTTP entirely. From there both sides send framed messages — text or binary — with almost no per-message overhead.

GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
The upgrade handshake, then a different protocol on the same socket.

What you have to build yourself, because the protocol gives you none of it:

  • Reconnection with exponential backoff and jitter.
  • Resume — a sequence number or cursor so a reconnect does not lose messages.
  • Heartbeats — ping/pong to detect a half-open connection the OS has not noticed.
  • Auth — browsers cannot set headers on the upgrade, so use a short-lived ticket in the query string or authenticate in the first message.
  • Message schema and routing — there is no method, no path, no status code. It is your protocol now.
WebSockets have no CORS
The same-origin policy does not apply to the upgrade. You must validate the Originheader yourself, or any site can open an authenticated socket to your server with the user's cookies attached.

Comparison

Short pollingLong pollingSSEWebSockets
DirectionPullPull (simulated push)Server → clientBidirectional
ProtocolHTTPHTTPHTTPTCP after upgrade
LatencyUp to the intervalNear real-timeReal-timeReal-time
PayloadAnyAnyUTF-8 textText or binary
Auto reconnectn/aManualBuilt in with Last-Event-IDBuild it yourself
Overhead per messageFull requestFull requestFew bytes2–14 bytes
Proxy / firewall safeAlwaysAlwaysAlmost alwaysSometimes blocked
Server costRequestsHeld connectionsHeld connectionsHeld connections
Best forSlow-changing dataUniversal fallbackFeeds, progress, tokensChat, games, collaboration

Scaling stateful connections

Long-lived connections break the assumption that any server can serve any request. A user's socket lives on exactly one node, and an event produced elsewhere has to find it.

API writesorder shippedPub/Sub backplaneRedis · NATS · Kafkatopic: order:8812Gateway node 1holds open socketsclientsGateway node 2holds open socketsclientsGateway node 3holds open socketsclientsApplication servers stay stateless; only the gateway tier is stateful, and it is the only tier that needs sticky routing.
Separate the stateful gateway tier from stateless application servers and connect them with pub/sub.
  • Connection budget — file descriptors, memory per connection, and ephemeral ports on the load balancer. 100k+ per node is achievable but must be tuned deliberately.
  • Backplane — Redis pub/sub, NATS or Kafka so any producer can reach any gateway.
  • Topic granularity — subscribe per room or per entity, never “broadcast everything and filter on the node”.
  • Deploys are the hard part — every restart drops every socket. Drain gradually and make clients reconnect with jitter, or you will thundering-herd yourself.
  • Idle timeouts — load balancers kill quiet connections; heartbeats must be shorter than the shortest timeout in the path.

Backpressure and slow consumers

A client on a train receives slower than you produce. Without a policy, the per-connection buffer grows until the node runs out of memory — one bad client degrading everyone.

  • Bound every outbound queue and pick a drop policy: newest-wins, coalesce, or disconnect.
  • Coalescing is usually right for state — send the latest snapshot, not fifty stale deltas.
  • Sample high-frequency telemetry server-side; a UI cannot render 1000 updates a second anyway.
  • Watch buffered-amount metrics per connection and disconnect the worst offenders deliberately.

Webhooks: push between servers

When the receiver is another server rather than a browser, the answer is a webhook: an HTTP POST to a URL the consumer registered. No connection to hold, no gateway tier.

const sig = req.header("X-Signature");
const ts  = Number(req.header("X-Timestamp"));
if (Math.abs(Date.now()/1000 - ts) > 300) return res.status(400).end();  // replay window

const expected = hmacSha256(secret, `${ts}.${rawBody}`);
if (!timingSafeEqual(sig, expected)) return res.status(401).end();

await queue.enqueue(rawBody);   // do the work asynchronously
return res.status(202).end();   // respond fast — senders time out in seconds
Verify the signature over the raw body, in constant time, with a timestamp window.
  • At-least-once delivery. Store the event id and ignore duplicates.
  • Out of order. Compare a version or timestamp before applying state.
  • Retries with backoff on the sender side, plus a dead-letter queue and a manual replay endpoint.
  • Never trust the payload alone for anything financial — treat it as a signal and re-read from the provider's API.

Choosing

RequirementUseWhy
Data changes every few minutesShort pollingCheapest thing that works; no new infrastructure
Notifications, live status, progress barsSSEOne-directional, auto-reconnect, plain HTTP
Streaming model output token by tokenSSEExactly the shape of the problem
Chat, cursors, multiplayer, tradingWebSocketsGenuinely bidirectional and low overhead
Hostile corporate networksLong polling fallbackNothing blocks plain HTTP
Notifying another company's serverWebhooksNo held connection, standard integration
Internal service streaminggRPC streamingTyped, multiplexed, with deadlines

Interview framing

“Live order status is one-directional, so it goes over SSE — the browser reconnects on its own and replays Last-Event-ID, and it is ordinary HTTP so auth and observability are unchanged. Chat needs client-to-server messages continuously, so that gets WebSockets with heartbeats, a sequence number for resume, and Origin validation because CORS does not apply to the upgrade. Connections terminate on a dedicated stateful gateway tier fed by Redis pub/sub, keeping the app servers stateless, and every outbound queue is bounded with a coalescing policy so one slow client cannot exhaust a node. Partners are notified with signed, idempotent webhooks rather than being asked to hold a connection.”

Keep reading

Suggested next articles based on this one.

Design it, don't just read it.

Practise LLD and system design problems with structured rubrics and AI feedback.

Start practising free