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 #

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.

Back to Real-Time Protocol Selection & Architecture