SSE and WebSockets
TL;DR
SSE gives you server push over plain HTTP with automatic reconnection and zero client-side libraries. WebSockets give you full-duplex communication over a persistent TCP connection with minimal framing overhead. WebRTC gives you peer-to-peer media streaming. Most apps need SSE or WebSockets, not both. The decision comes down to one question: does the client need to send frequent messages back to the server over the same connection?
Server-Sent Events — Push Over Plain HTTP
SSE is criminally underused. It's a one-way channel from server to client that runs over a standard HTTP connection. No upgrade handshake, no special protocol, no libraries needed.
The Wire Format
The server responds with Content-Type: text/event-stream and sends newline-delimited events:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
id: 1
event: notification
data: {"user": "alice", "text": "New comment on your post"}
id: 2
event: notification
data: {"user": "bob", "text": "Liked your photo"}
: this is a comment (heartbeat)
id: 3
event: price-update
data: {"ticker": "AAPL", "price": 189.42}
That's the entire protocol. Text lines, separated by double newlines. No binary framing, no handshake, nothing to negotiate.
Server Implementation
from flask import Response, stream_with_context
import json, time, queue
# Per-client message queue
clients = {}
@app.route('/api/stream')
def sse_stream():
client_id = request.args['client_id']
q = queue.Queue()
clients[client_id] = q
def generate():
try:
while True:
try:
msg = q.get(timeout=30)
yield f"id: {msg['id']}\n"
yield f"event: {msg['type']}\n"
yield f"data: {json.dumps(msg['payload'])}\n\n"
except queue.Empty:
yield ": heartbeat\n\n" # keep connection alive
finally:
clients.pop(client_id, None)
return Response(
stream_with_context(generate()),
content_type='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no', # disable Nginx buffering
}
)
Client Implementation
const source = new EventSource('/api/stream?client_id=user123');
source.addEventListener('notification', (e) => {
const data = JSON.parse(e.data);
showNotification(data);
});
source.addEventListener('price-update', (e) => {
const data = JSON.parse(e.data);
updateTicker(data.ticker, data.price);
});
// Reconnection is AUTOMATIC — browser handles it
source.onerror = (e) => {
console.log('Connection lost, browser will reconnect...');
// EventSource sends Last-Event-ID header on reconnect
// Server can resume from that point
};
Why SSE Is Great
| Feature | Benefit |
|---|---|
Last-Event-ID header |
Browser sends this on reconnect — server resumes from exact point |
| Auto-reconnect | Built into the spec, 3-second default retry |
| HTTP/2 multiplexing | Multiple SSE streams share one TCP connection (huge at scale) |
| No special infrastructure | Works through CDNs, proxies, load balancers — it's just HTTP |
| Named events | Route different event types to different handlers |
| Text-only | Simple debugging, curl works perfectly |
Interview Tip
If the interviewer asks you to design a notification system or live feed, SSE is almost always the right answer. It's simpler than WebSockets, works through every proxy and CDN, and handles reconnection automatically. Save WebSockets for when you need the client to send data back.
WebSockets — Full-Duplex Persistent Connections
When you need both sides talking freely over the same connection, WebSockets are the protocol.
The Upgrade Handshake
A WebSocket connection starts as a normal HTTP request, then upgrades to a persistent, bidirectional TCP pipe via the HTTP 101 Switching Protocols handshake. For protocol-level details on WebSocket handshakes and framing, see our Networking course (Ch 4). Here we focus on when to choose each protocol and how to architect fan-out at scale.

Server Implementation
# Using the websockets library (asyncio-based)
import asyncio
import websockets
import json
connected = set()
async def handler(ws):
connected.add(ws)
try:
async for raw in ws:
msg = json.loads(raw)
if msg['type'] == 'message':
# Broadcast to all connected clients
payload = json.dumps({
'type': 'message',
'from': msg['user'],
'text': msg['text'],
'ts': time.time()
})
await asyncio.gather(
*[c.send(payload) for c in connected if c != ws],
return_exceptions=True
)
elif msg['type'] == 'ping':
await ws.send(json.dumps({'type': 'pong'}))
finally:
connected.discard(ws)
async def main():
async with websockets.serve(handler, "0.0.0.0", 8765):
await asyncio.Future() # run forever
Client Implementation
class ReliableWebSocket {
constructor(url) {
this.url = url;
this.reconnectDelay = 1000;
this.maxDelay = 30000;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.reconnectDelay = 1000; // reset backoff
this.startHeartbeat();
};
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'pong') return; // heartbeat response
this.onMessage(msg);
};
this.ws.onclose = () => {
this.stopHeartbeat();
setTimeout(() => this.connect(), this.reconnectDelay);
this.reconnectDelay = Math.min(
this.reconnectDelay * 2,
this.maxDelay
);
};
}
startHeartbeat() {
this.heartbeat = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000); // every 30 seconds
}
stopHeartbeat() {
clearInterval(this.heartbeat);
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
}
The Operational Tax
WebSockets buy you power but come with baggage:
| Challenge | Why It Hurts |
|---|---|
| Sticky sessions | Client must reconnect to the same server (or use external pub/sub) |
| No auto-reconnect | You build it yourself — exponential backoff, state reconciliation |
| Proxy/LB complexity | Many proxies don't handle Upgrade; needs explicit WebSocket support |
| No multiplexing | One TCP connection per WebSocket (HTTP/2 won't help you here) |
| Heartbeats required | Without ping/pong, idle connections get killed by NATs and firewalls |
| Auth on reconnect | Token in query param (visible in logs) or first-message auth pattern |
Load Balancer Gotcha
The HTTP upgrade handshake means your load balancer must support WebSocket connections and hold them open. If you're using L7 load balancing, you need sticky sessions or a separate pub/sub layer (Redis, Kafka) so any server can deliver messages to any client. This is the single biggest operational headache with WebSockets at scale.
WebRTC — Peer-to-Peer Media
WebRTC is a different beast entirely. It's for sending audio, video, and data directly between browsers over UDP, bypassing your servers almost entirely.

Scaling WebRTC Beyond 1:1
For group calls, P2P mesh falls apart fast (N peers = N-1 connections each). Two server architectures solve this:
| Architecture | How It Works | Tradeoff |
|---|---|---|
| SFU (Selective Forwarding Unit) | Server receives one stream, forwards to all participants. No transcoding. | Low server CPU, each client downloads N-1 streams |
| MCU (Multipoint Control Unit) | Server mixes all streams into one composite. | High server CPU, each client downloads 1 stream |
Most modern platforms (Zoom, Google Meet, Discord) use SFUs because CPU is expensive and clients have gotten powerful enough to decode multiple streams.
Interview Tip
WebRTC only comes up in "design a video calling system" interviews. For chat, notifications, live feeds, or collaboration tools — it's the wrong answer. Mention it briefly to show awareness, then focus on SSE or WebSockets.
The Decision Matrix
This is the table you want in your back pocket during a system design interview:
| Criteria | SSE | WebSocket | WebRTC | Long Polling |
|---|---|---|---|---|
| Direction | Server -> Client | Bidirectional | P2P Bidirectional | Server -> Client |
| Protocol | HTTP | TCP (after upgrade) | UDP (DTLS/SRTP) | HTTP |
| Latency | Low (~50ms) | Very Low (~10ms) | Lowest (~1-5ms) | Medium (~100ms+) |
| Data format | Text only | Text + Binary | Text + Binary + Media | Any |
| Reconnection | Automatic (built-in) | Manual (you build it) | Manual (re-negotiate) | Manual |
| Through proxies | Yes (it's HTTP) | Sometimes (needs support) | Rarely (needs TURN) | Yes (it's HTTP) |
| HTTP/2 benefits | Yes (multiplexing) | No | No | Limited |
| Browser support | All modern | All modern | All modern | All |
| Complexity | Low | Medium | High | Low |
| Scaling difficulty | Low | Medium-High | High | Medium |
Quick Decision Flow

Combining Protocols
Real systems often use multiple protocols. A common pattern:

GitHub uses SSE for live-updating pull request checks and commit statuses, keeping the infrastructure simple since updates only flow from server to client. Slack uses WebSockets for its real-time messaging because both sides need to send data — messages, typing indicators, and presence updates.
Key Takeaways
| Protocol | Use When | Avoid When |
|---|---|---|
| SSE | Server-to-client push, notifications, feeds, tickers | Client needs to send frequent data back |
| WebSocket | Chat, collaboration, gaming, any bidirectional real-time | Simple server push (use SSE instead) |
| WebRTC | Video/audio calls, screen sharing, P2P file transfer | Text-based real-time (massive overkill) |
| Long Polling | Legacy browser support, simple fallback | High concurrency (wastes connections) |
The Most Common Mistake
Reaching for WebSockets when SSE would do. If the server is the only one pushing data, SSE is simpler to build, simpler to scale (it's just HTTP), and handles reconnection automatically. WebSockets are a tool for bidirectional communication, not a "better" version of SSE.