WebSocket vs SSE vs WebRTC: Choosing a Real-Time Transport #
A team ships a “live” dashboard on WebSockets, then watches memory climb on every box: 40,000 idle connections, each holding a TCP socket and a file descriptor, all to push a number that changes once a second. Server-Sent Events would have carried that one-way feed over plain HTTP with a fraction of the state. The opposite failure is just as common — a multiplayer game built on WebSockets that stutters because head-of-line blocking on a single TCP stream delays position updates behind a dropped packet that no longer matters.
Picking the wrong transport is expensive to undo because it leaks into your load balancer config, your reconnect logic, and your scaling model. This guide gives you a concrete decision procedure: match your duplex requirement, latency budget, and delivery semantics against what each protocol actually guarantees, then verify the choice before it hardens into infrastructure. The broader selection criteria — connection density, payload serialization, NAT topology — live in Real-Time Protocol Selection & Architecture.
Prerequisites #
Before comparing transports, you should have:
- A working WebSocket upgrade path on your reverse proxy. The header rules that make
Upgrade/Connectionsurvive a proxy hop are covered in Browser Compatibility & Polyfills. - Familiarity with the HTTP-to-WebSocket handshake and the
101 Switching Protocolsresponse, detailed in Protocol Handshake Mechanics. - A defined latency budget per message type. “Real-time” is not a number; sub-50ms voice and 1s dashboard ticks demand different transports.
- A view of your connection density per node, since each persistent WebSocket consumes a file descriptor and kernel socket buffers.
The decision at a glance #
The hardest part of this choice is that three axes interact: who initiates messages (duplex), what the message guarantees are (ordered/reliable vs fast), and where the bytes flow (through your server or peer-to-peer). The matrix below maps the three transports onto those axes so you can rule out two of them quickly.
The narrow version: if the client almost never sends data, reach for SSE first. If the client sends frequently and you need request/response over one connection, use WebSocket. If you need the lowest possible latency, unreliable-but-fast delivery, or to keep media off your servers, use WebRTC — and accept the signaling and NAT-traversal complexity that comes with it.
Comparison matrix #
| Dimension | WebSocket | SSE | WebRTC (data channel) |
|---|---|---|---|
| Duplex | Full-duplex | Server to client only | Full-duplex, peer to peer |
| Transport | One TCP stream, framed | HTTP response body, chunked | UDP via SCTP (DTLS-secured) |
| Latency | Low, 10-50ms typical | Low, 10-50ms typical | Lowest, sub-50ms achievable |
| Ordering / reliability | Ordered, reliable (TCP) | Ordered, reliable (TCP) | Configurable: reliable or unreliable/unordered |
| Reconnect | Manual; rebuild app state | Built-in: Last-Event-ID resume |
Full ICE renegotiation; expensive |
| Head-of-line blocking | Yes (single TCP stream) | Yes (single TCP stream) | Avoidable on unreliable channels |
| Infra overhead | 1 fd + socket buffers per client | 1 fd per client; multiplexes on HTTP/2 | STUN/TURN servers, signaling channel |
| Browser API | WebSocket |
EventSource |
RTCPeerConnection + RTCDataChannel |
| Best fit | Chat, collaboration, mutations | Dashboards, notifications, feeds | Voice, video, real-time game state |
Core implementation #
The cleanest way to feel the difference is to stand up the two server-routed transports side by side from one Node process. Both terminate on the same HTTP server; only the response semantics differ. The WebSocket path negotiates an upgrade and then frames messages in both directions; the SSE path keeps an HTTP response open and writes text/event-stream chunks.
import * as http from 'http';
import { WebSocketServer, WebSocket } from 'ws';
import type { IncomingMessage, ServerResponse } from 'http';
const SSE_RETRY_MS = 3000; // tells the browser how long to wait before reconnecting
const HEARTBEAT_INTERVAL_MS = 25000; // keep idle SSE streams from being reaped by proxies
const server = http.createServer((req: IncomingMessage, res: ServerResponse) => {
// SSE: one-way, server-to-client, plain HTTP — no upgrade, no framing
if (req.url === '/events') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform', // no-transform stops proxies buffering the stream
Connection: 'keep-alive',
});
res.write(`retry: ${SSE_RETRY_MS}\n\n`); // browser auto-reconnects on drop using this interval
const tick = setInterval(() => {
// `id:` lets the client resume via Last-Event-ID after a reconnect
res.write(`id: ${Date.now()}\nevent: metric\ndata: ${JSON.stringify({ cpu: load() })}\n\n`);
}, 1000);
const beat = setInterval(() => res.write(': keep-alive\n\n'), HEARTBEAT_INTERVAL_MS); // comment line
req.on('close', () => { clearInterval(tick); clearInterval(beat); }); // client gone: stop writing
return;
}
res.writeHead(404).end();
});
// WebSocket: full-duplex, client may push at any time
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
if (req.url !== '/ws') { socket.destroy(); return; } // only upgrade the intended path
wss.handleUpgrade(req, socket, head, (ws: WebSocket) => {
ws.on('message', (data: Buffer) => {
// Inbound client message — the capability SSE simply does not have
ws.send(JSON.stringify({ ack: true, echoed: data.toString() }));
});
});
});
server.listen(8080);
function load(): number { return Math.round(process.cpuUsage().user / 1000); }
Notice what the SSE branch does not need: no subprotocol negotiation, no frame masking, no ping/pong machinery. The browser’s EventSource reconnects on its own and replays Last-Event-ID. That built-in resume is the main reason SSE wins for pure server-push feeds — you write less reconnect code and lose less state. The trade you make for choosing one full-duplex stream is covered in depth in When to use WebSockets over Server-Sent Events.
Configuration reference #
| Parameter | Type | Default | Production value | Notes |
|---|---|---|---|---|
proxy_read_timeout (nginx) |
duration | 60s |
3600s+ |
Too low and idle WS/SSE streams are killed mid-session |
Cache-Control (SSE) |
header | none | no-cache, no-transform |
no-transform stops proxies from buffering the stream |
SSE retry: |
ms | browser default ~3s | 3000 |
Client reconnect interval after a drop |
| WS heartbeat interval | ms | none | 25000 |
Below proxy idle timeout; detects dead peers |
maxPayload (ws) |
bytes | 104857600 |
65536–1048576 |
Cap inbound frames to bound memory per client |
ordered (RTCDataChannel) |
bool | true |
false for game state |
Unordered avoids head-of-line blocking on stale packets |
maxRetransmits (RTCDataChannel) |
number | unlimited | 0 for telemetry |
0 = fire-and-forget, lowest latency |
Edge cases & gotchas #
- SSE’s six-connection ceiling on HTTP/1.1. Browsers cap concurrent connections per origin at ~6. Six
EventSourcestreams to the same host exhaust the pool and block everything else, including your REST calls. Serve SSE over HTTP/2 so many streams multiplex over one TCP connection, or shard across subdomains. - WebRTC needs a signaling channel anyway. A
RTCPeerConnectioncannot bootstrap itself; SDP offers/answers and ICE candidates must travel over a separate channel — almost always a WebSocket. “WebRTC vs WebSocket” is rarely either/or; you usually run both. - Head-of-line blocking is silent. On WebSocket and SSE a single dropped TCP segment stalls every message behind it until retransmission. For position updates where only the newest value matters, that retransmitted stale frame is pure latency. This is the core reason game state often belongs on a WebRTC data channel with
maxRetransmits: 0. - Proxy buffering swallows SSE. Without
Cache-Control: no-transform(andproxy_buffering offon nginx), an intermediary may hold your event-stream chunks until the buffer fills, turning “real-time” into “every few kilobytes.”
Verification #
Confirm the transport actually behaves as chosen before you scale it.
# SSE: should stream events, never closing, with text/event-stream content-type
curl -N -H "Accept: text/event-stream" http://localhost:8080/events
# WebSocket: confirm the 101 upgrade actually completes through your proxy
curl -i -N \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
http://localhost:8080/ws # expect: HTTP/1.1 101 Switching Protocols
# Count live persistent connections per node to size fd limits
ss -tnp state established '( dport = :8080 or sport = :8080 )' | wc -l
In Chrome DevTools, the Network tab tags SSE responses as eventsource and WebSocket as websocket (with a Messages sub-tab for frames). For WebRTC, open chrome://webrtc-internals and confirm the data channel’s state is open and whether it negotiated ordered/reliable as you intended.
Guides in this area #
- When to use WebSockets over Server-Sent Events walks the decision between full-duplex and one-way push when your client only occasionally talks back.
- WebRTC data channel vs WebSocket for game state shows why unreliable, unordered delivery beats TCP for fast-moving position updates.
- HTTP/2 server push vs WebSocket clears up why deprecated HTTP/2 push is not a real-time transport and what to use instead.
FAQ #
Is SSE just a worse WebSocket? #
No — it is a different tool. SSE carries server-to-client data only, but in exchange it runs over ordinary HTTP, needs no upgrade handshake, and reconnects automatically with Last-Event-ID replay. For a feed the client never writes back to (dashboards, notifications, log tails), SSE is less code and less state to manage than a WebSocket.
Can I run SSE and WebSocket on the same server and port? #
Yes. SSE is a normal GET that keeps its HTTP response open, and WebSocket is an upgrade on a different path. The Core implementation above serves both from one http.Server on port 8080 — route SSE to /events and the upgrade to /ws.
Why does WebRTC need a WebSocket if it is peer-to-peer? #
The media or data path is peer-to-peer, but peers must first exchange SDP offers/answers and ICE candidates to discover how to reach each other through NATs. That exchange needs a pre-existing channel, and a WebSocket to your signaling server is the usual choice. Once the peer connection is established, the data channel bypasses your server.
Does this work behind AWS ALB? #
WebSocket and SSE both work behind an Application Load Balancer, but raise the idle timeout above your heartbeat interval or the ALB will close idle streams. WebRTC media does not flow through an HTTP load balancer at all — only its signaling WebSocket does.
When should I pick WebRTC over WebSocket? #
When you need the lowest possible latency with unreliable/unordered delivery (fast game state), or when routing media directly between peers keeps bandwidth and compute off your servers (voice/video). If your data must be ordered and reliable and a few tens of milliseconds is fine, a WebSocket is simpler and cheaper to operate.
Related #
- Protocol Handshake Mechanics — the frame-level RFC 6455 upgrade that WebSocket relies on.
- Browser Compatibility & Polyfills — keeping
Upgradeheaders andEventSourceworking across proxies and clients. - Security & TLS Configuration — securing
wss://and origin checks once a transport is chosen. - When to use WebSockets over Server-Sent Events — the duplex decision in detail.