The Runtime Theory
Backend Engineering

WebSocket vs SSE vs Polling: Real-Time Transport Compared

WebSocket frames, SSE event streams, and HTTP polling compared: latency, bandwidth, server costs, keepalives, and the case where each is the right answer.

The Runtime Theory Team4 min read#websockets#sse#polling#real-time#http
On this page

Real-time delivery has exactly three mainstream mechanisms — HTTP polling, Server-Sent Events, and WebSockets — and every "real-time framework" is a wrapper around one of them. The differences are not about speed hype; they're about who holds state, who pays for idle connections, and what the wire protocol actually does. This article compares the three on the mechanisms that matter: frames, keepalives, connection state, and total cost per concurrent user.

HTTP polling: state-free and expensive

Polling is real-time by repetition. The client issues a normal HTTP request, gets a normal HTTP response, waits, repeats:

plaintext
GET /updates?since=48211      -> 200 [empty or events]
...sleep 5s...
GET /updates?since=48211      -> 200 [events]

Long polling is the refinement: the server holds the connection open (with a timeout, typically 25–60s) until an event exists or the deadline hits, so each round trip delivers an event at latency equal to the event's arrival time — no fixed-interval delay. The cost profile is the same shape, though: each poll is a full HTTP transaction with headers, TLS overhead, and connection setup (or keep-alive reuse).

Costs are arithmetic and easy to compute: at 5-second polling, one user generates 17,280 requests/day. At 10,000 users that's ~173M requests/day of pure waste if events are rare. Where polling still wins: firewalls that block long-lived connections, simple one-shot clients (CLIs, cron), and event frequency high enough (per-second metrics) that the waste is low relative to the data.

Server-Sent Events: one-way, HTTP-native

SSE is a long-lived HTTP response that the server writes to incrementally. The client opens one connection; the server streams text/event-stream frames forever:

plaintext
HTTP/1.1 200 OK
Content-Type: text/event-stream
 
data: {"order": 48211, "status": "paid"}
 
retry: 3000
 
data: {"order": 48211, "status": "shipped"}

Key mechanics:

  • One direction. SSE is server-to-client only; the client communicates via regular HTTP requests. That asymmetry is a feature: it rides on plain HTTP (any load balancer, proxy, CDN works — no upgrade handshake), and it's trivially compatible with HTTP/2 multiplexing.
  • Event framing. Lines with data:, event:, id:, retry: form discrete events. The id: field enables the client to send Last-Event-ID on reconnect, giving automatic resume — the server can replay missed events. WebSockets have no such built-in resume; that's SSE's biggest practical advantage.
  • Auto-reconnect. The browser spec defines reconnection behavior on connection drop, including the retry: backoff. On plain WebSockets you write this yourself.

The one-directional constraint is the real limit. If the client must push commands or chat messages, you're layering a second transport on top — at which point a single WebSocket is simpler.

WebSockets: bidirectional frames on an upgraded connection

A WebSocket starts as HTTP, then upgrades: the client sends Upgrade: websocket, the server replies 101 Switching Protocols, and both ends swap to a persistent bidirectional stream of frames — small binary or text messages with their own framing:

plaintext
client:  GET /ws HTTP/1.1
         Host: api.example.com
         Upgrade: websocket
         Connection: Upgrade
         Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
 
server:  HTTP/1.1 101 Switching Protocols
         Upgrade: websocket
         Connection: Upgrade
         Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Mechanically, the important bits:

  • Full-duplex. Both directions share one connection; no separate HTTP calls for client commands.
  • Frame types. Text and binary data frames carry payloads; control frames — ping/pong and close — manage the connection. Because TCP alone signals nothing about application liveness, WebSocket stacks rely on application-level ping/pong keepalives: a peer that misses N pongs (e.g. 3 × 30s) is declared dead and the connection is torn down. Without keepalives, dead peers linger as half-open connections until the TCP timeout (often minutes to hours).
  • No automatic resume. If the connection drops, everything not yet delivered is gone; the client reconnects and you need your own state reconciliation (sequence numbers, last-seen IDs, re-subscribe handshake).

Server cost is connection state. Every WebSocket holds memory (buffer, context, protocol state) and a file descriptor, and idle connections consume keepalive traffic. An NGINX node doing ~1M idle WebSocket connections will consume memory in the GB range for connection state alone — one reason chat apps measure "connections per node" as a hard capacity number.

The comparison table

PollingSSEWebSocket
DirectionBoth (via requests)Server → clientFull-duplex
ProtocolPlain HTTPHTTP streamingHTTP upgrade + frames
Idle costN requests/sec/user1 idle connection1 idle connection + state
Reconnect/resumeStatelessBuilt-in (Last-Event-ID)Manual
Proxies/CDNsFirst-classFirst-classUpgrade handshake quirks
Client → server dataAny HTTP callSeparate HTTP callsSame connection
FitsLow-frequency, simpleNotifications, feeds, logsChat, collab, games, live ops

Choosing

Pick polling when events are rare or clients are dumb. Pick SSE when the flow is one-way notifications — order status, feed updates, progress bars — because you get resume and reconnect semantics for free and every intermediary treats it as HTTP. Pick WebSocket when the client needs to send as often as it receives (chat, presence, multiplayer, terminal) — and when you do, set ping/pong keepalives with a deadline, reconnect with a backoff and a sequence watermark, and budget idle connections as first-class server capacity.

The mistake to avoid is choosing by buzzword: a "real-time order tracker" that only pushes status is an SSE application wearing a WebSocket costume, paying for bidirectional state it never uses.