Skip to content

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.

WebSocket handshake and full-duplex communication

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.

WebRTC architecture: signaling server, peer-to-peer media, and NAT traversal

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

Protocol decision flowchart: choosing between WebRTC, WebSocket, SSE, and polling


Combining Protocols

Real systems often use multiple protocols. A common pattern:

Combining protocols: SSE for notifications, WebSocket for chat, REST for CRUD

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.