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.
Re-deciding as the product grows #
Transport choices age. The decision that was correct for a notification feed at launch is often wrong once the product grows a collaborative surface, and the cost of not revisiting it is a system quietly fighting its own transport.
Three signals say a re-decision is due. Client-to-server messages over a one-way transport — an SSE feed paired with a POST endpoint for every user action is a WebSocket assembled badly, paying two connection setups and losing any ordering relationship between the two channels. A message rate in the tens per second per client, where per-message HTTP overhead stops being noise and becomes most of your bandwidth. And a latency requirement that a relayed transport cannot meet, at which point the question stops being WebSocket versus SSE and becomes whether a peer-to-peer data channel is worth its signalling and NAT-traversal complexity.
Three signals say you over-chose. A WebSocket carrying one update a minute has bought a persistent connection, a heartbeat, a reconnect state machine and a sticky-session requirement to replace a poll. A WebSocket used exclusively server-to-client is an SSE feed that gave up automatic reconnection and browser-native resume for nothing. And a WebSocket in an environment where a meaningful share of users sit behind inspecting proxies may be delivering a worse experience than polling would, for exactly the users who matter most.
The useful way to hold this is as a constraint problem on four axes — direction, frequency, latency tolerance and network hostility — of which only the last is outside your control. Write down where the product sits on each, revisit when any moves by an order of magnitude, and treat “we already use WebSockets everywhere” as a description of the present rather than an argument about the future.
The practical consequence is that mature systems usually run more than one transport, split by message class rather than choosing a single winner: authoritative state and anything that must not be lost over a reliable channel, high-frequency ephemeral updates over whatever is cheapest for them.
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.
Can I migrate from SSE to WebSockets later without a rewrite? #
Yes, if the message envelope is identical on both. Keep the transport behind a thin interface that exposes connect, disconnect and a message callback, and the migration becomes swapping one module rather than touching every consumer. What makes it expensive is building a bidirectional protocol on SSE plus POST first, because that shape has no equivalent to migrate to.
Which transport is best for a mobile client? #
Whichever recovers best from network transitions, which usually means a WebSocket with a well-built reconnect and resume, or SSE if the traffic is one-way. Mobile clients change networks constantly, so the transport that matters is the one whose reconnect is cheapest — which is an argument for session resumption over raw connection speed.
Do I have to pick one transport for the whole product? #
No, and mature systems rarely do. Splitting by message class — reliable channel for state and commands, cheapest available channel for high-frequency ephemeral updates — matches how the data actually behaves. The cost is operating two paths; the benefit is that neither class is paying for guarantees it does not need.
How does each transport behave on a flaky mobile connection? #
SSE recovers best without help, because EventSource reconnects on its own and replays from the last event id it saw. A WebSocket recovers fastest once you have built backoff and resume, and worst if you have not. A WebRTC data channel is the most fragile of the three, since a network change usually requires renegotiating ICE candidates entirely. For a product whose users are mostly mobile, that ordering should carry real weight in the decision.
Which transport costs the most to operate? #
WebRTC, by a wide margin, because it needs signalling, STUN and a TURN relay for the meaningful minority of users behind symmetric NAT — and relay bandwidth is billed. WebSockets cost connection memory and a fan-out path. SSE is the cheapest to operate and the most limited. Operational cost rarely decides the choice on its own, but it should be on the table alongside latency, because it is the axis teams most often discover after committing.
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.