Chat, live notifications, real-time dashboards — the server has to push data to the client. Plain HTTP's request/response loop doesn't fit. The answers are WebSocket and Server-Sent Events (SSE). How do they differ? How does bidirectional traffic ride on top of HTTP? This guide walks through the WebSocket handshake, SSE's EventSource model, the long-polling fallback, and which to pick for which job.
Why HTTP alone falls short
Default HTTP pattern:
Client → Request → Server
Server → Response → Client
↓ connection closes
How to receive chat messages?
- Poll every second?
→ 99% empty responses, brutal load
- Have the server push directly?
→ How? It doesn't know the client's IP/port, and there's NAT in the wayReal-time means "server can push" + "client can send" — both directions.
The old fix — long polling
Client → GET /messages?since=0 (HTTP)
Server: holds the response open
...
New message arrives!
Server → 200 OK, {messages: [...]}
↓ connection closes
Client → GET /messages?since=last (immediately)
...Pros — vanilla HTTP, plays well with proxies and firewalls. Cons:
- New connection per message → overhead
- HTTP headers repeat (kilobytes each)
- Server timeouts during the hold
Used to be standard pre-2010. Today it's a fallback only.
WebSocket — bidirectional, single connection
Standardized in 2011 (RFC 6455). One HTTP handshake upgrades the connection to WebSocket; from there, bidirectional byte streams flow on the same TCP socket:
Client → GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Server → HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
→ Now the same TCP connection carries WebSocket frames both waysSec-WebSocket-Accept = SHA-1(client key + magic string). It's not a plain echo, which keeps caching proxies from accidentally completing the handshake.
WebSocket frame layout
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| op |M| Payload len | Extended payload length |
|I|S|S|S| code |A| (7) | (16/64) |
|N|V|V|V| (4) |S| | |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+-------------------------------+
op code:
0x1 = text frame (UTF-8)
0x2 = binary frame
0x8 = close frame
0x9 = ping frame
0xA = pong framePer-frame overhead is 2-14 bytes vs HTTP's 200-2000 bytes of headers. Thousands of messages per second are routine.
Client code
const ws = new WebSocket("wss://example.com/ws");
ws.onopen = () => {
ws.send("Hello server!");
};
ws.onmessage = (event) => {
console.log("received:", event.data);
};
ws.onclose = (event) => {
console.log("closed:", event.code, event.reason);
};
ws.onerror = (event) => {
console.error("error:", event);
};Server-Sent Events (SSE) — one-way push
W3C standard since 2009. Reuses HTTP's long-running response with a small text wrapper:
Client:
const source = new EventSource("/events");
source.onmessage = (e) => {
console.log("server pushed:", e.data);
};
Server (Node):
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
// per event
res.write(`data: ${JSON.stringify(msg)}\n\n`);
// keep the connection open, never .end()Wire format is plain text:
event: chat-message
data: {"user":"Alice","text":"hi"}
id: 42
event: notification
data: New user joined
id: 43
(blank line separates events)SSE strengths
- Plain HTTP — proxies and firewalls don't blink
- Automatic reconnection — EventSource detects drops and reconnects.
Last-Event-IDheader lets the server resume from missed events - Native browser API — EventSource, no library needed
SSE weaknesses
- One-way only — server → client. Client → server still needs separate HTTP POSTs
- Browser connection limit — 6 per domain (HTTP/1.1). Multiple SSE tabs share the cap
- No binary — text/event-stream only; base64 if you must
WebSocket vs SSE — at a glance
| WebSocket | SSE | |
|---|---|---|
| Direction | Bidirectional | Server → client only |
| Protocol | ws:// / wss:// | http:// / https:// |
| Binary | ✅ | X (base64 needed) |
| Auto reconnect | Roll your own | ✅ built-in |
| Proxy / Firewall | Sometimes blocked | Pure HTTP, passes |
| Multiplexing | One connection + app topics | One connection per stream |
| Per-message overhead | 2-14 bytes | ~50 bytes (text framing) |
| HTTP/2 multiplexing | X (separate protocol) | ✅ (HTTP/2) |
| Server library | Dedicated (Socket.IO, ws) | Vanilla HTTP, none needed |
When to use which
Choose WebSocket
- Chat (bidirectional, low latency)
- Real-time multiplayer games
- Collaborative editing (Google Docs-style)
- Trading / financial tickers (lots of binary)
Choose SSE
- Notifications (server → client only)
- Live dashboards (stock prices, server metrics)
- Activity feeds (Twitter, GitHub notifications)
- Build / job progress (CI log streams)
- Client → server can stay vanilla HTTP (form posts)
Other contenders
- WebRTC — peer-to-peer + UDP. Low-latency voice / video / games. Less server load.
- HTTP/2 Server Push — preload-only; not general-purpose real-time. Chrome deprecated it in 2022.
- Long polling — legacy fallback. Last resort.
Heartbeats and reconnection
WebSocket — ping/pong
Server → ping frame (op 0x9)
Client → automatic pong frame (op 0xA)
No pong for 30+ seconds → connection is dead → reconnectCloudflare / nginx idle-timeout connections at 60-100s by default. 30-second heartbeats keep them alive.
SSE — automatic reconnect
Server keeps it alive with a comment line:
: heartbeat
EventSource:
- Detects connection drop
- Honors server-supplied retry: 3000 ms
- Reconnects after 3 seconds
- Sends Last-Event-ID with the last seen id
- Server resumes from missed eventsAuth and security
- WebSocket — handshake is an HTTP request (cookies + tokens included). Authenticate with the first message or a query-param token.
- SSE — plain HTTP. Cookies, Authorization headers, and CORS apply naturally.
WebSocket gotcha — missing Origin checks enable CSWSH (Cross-Site WebSocket Hijacking). SSE inherits CORS.
Common pitfalls
1. Browser connection cap
HTTP/1.1 caps domain connections at 6. Many SSE tabs + normal fetches starve each other. HTTP/2 multiplexes around this.
2. CDN / proxy buffering
nginx and Cloudflare buffer SSE by default — events arrive in chunks instead of in real time. Set X-Accel-Buffering: no and configure the proxy.
3. Reconnect storms
After a server outage, everyone reconnects at once → thundering herd → another outage. Always exponential backoff with jitter.
4. Memory leaks
WebSocket message handlers that capture large outer-scope objects keep them alive. Call ws.close() and detach listeners explicitly.
5. Backpressure
A slow client paired with a fast publisher fills the server's buffer. Drop oldest, throttle, or coalesce.
References
- RFC 6455 (WebSocket) — datatracker
- MDN — Server-Sent Events — MDN
- MDN — WebSockets API — MDN
- WHATWG HTML — EventSource spec — WHATWG
Summary
- Real-time options — long polling (legacy) / WebSocket (bidirectional) / SSE (server push) / WebRTC (peer-to-peer).
- WebSocket — handshake over HTTP, then bidirectional binary or text frames. ws:// / wss://. Best for chat, games, and true two-way streams.
- SSE — long-running HTTP response with text/event-stream. One-way, auto-reconnect, proxy-friendly. EventSource API.
- Per-message overhead — WebSocket 2-14 bytes, SSE ~50, HTTP 1-2 KB. WebSocket is the leanest.
- SSE's simplicity (one-way + HTTP + auto-reconnect) makes it the first pick for notifications and dashboards.
- Heartbeats are mandatory — ping/pong for WebSocket, comment lines for SSE.
- Watch out for CDN / proxy buffering —
X-Accel-Buffering: no. - On reconnect: exponential backoff with jitter to avoid thundering herds.