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/Connection survive a proxy hop are covered in Browser Compatibility & Polyfills.
  • Familiarity with the HTTP-to-WebSocket handshake and the 101 Switching Protocols response, 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.

Transport selection matrix A matrix mapping WebSocket, SSE, and WebRTC against duplex direction, transport layer, latency, and routing path. WebSocket SSE WebRTC Duplex Bi-directional Server to client Peer to peer Transport TCP (one stream) HTTP response UDP (SCTP) Latency Low (10-50ms) Low (10-50ms) Lowest (sub-50) Routing Through server Through server Direct peer link Rule out two transports per row, keep what survives all four

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 655361048576 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 EventSource streams 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 RTCPeerConnection cannot 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 (and proxy_buffering off on 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 #

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.

Four axes, and what each pushes you towards A table mapping each of the four decision axes to the transport it favours and the warning sign that the current choice no longer fits. Four axes, and what each pushes you towards Pushes towards Warning sign Bidirectional WebSocket SSE plus POST High frequency WebSocket or data channel polling at 1 Hz Loss-tolerant, low latency unreliable data channel head-of-line stalls One-way, resumable SSE hand-built resume Hostile networks 443 + polling floor no fallback at all Most systems need different answers for different message classes rather than one answer for everything
Four axes, and only the last one is outside your control.

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.

Bytes on the wire per update, by transport Wire bytes per update for payloads of 16, 64, 256, 1024 bytes: a WebSocket frame adds two to four bytes, an SSE event eight, and a polling round trip repeats 420 bytes of headers. Bytes on the wire per update, by transport frame/protocol overhead only — polling assumes 420 B of HTTP headers WebSocket frame SSE event HTTP poll 0 B 500 B 1.0k B 1.5k B 16 B 64 B 256 B 1024 B
Wire cost per update computed from each transport's framing rules. At small payload sizes the difference between transports is almost entirely HTTP header repetition.

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.

Back to Real-Time Protocol Selection & Architecture