System Design

WebSockets and SSE

Protocols for real-time communication between browser and server, without the client needing to request data constantly. WebSocket for bidirectional flow; SSE for simple unidirectional push; Long Polling as a universal fallback.

Intent

Classic HTTP is request-response: the client asks, the server answers, and the connection closes. For real time — chat, games, notifications, live dashboards — this model requires continuous polling, which wastes bandwidth and increases latency. WebSocket, SSE, and Long Polling eliminate that waste in different ways.

The choice between the three depends mainly on two dimensions: the direction of data flow (bidirectional or server→client only) and the infrastructure requirements (corporate proxies, load balancers, whether HTTP/2 is supported).

Problem

Classic HTTP was designed for the "web document" model: the browser requests a resource, the server delivers it, and the connection closes. That's efficient for static pages, but creates three problems for real-time applications:

  • Polling latency: if the browser checks for new data every second, the effective latency is up to 1 second even when the server has data immediately. Reducing the interval increases network cost.
  • Per-request overhead: every HTTP request carries headers of tens to hundreds of bytes (cookies, User-Agent, Accept, etc.). In a chat application with 100 messages per minute, this overhead far exceeds the useful content.
  • The server can't initiate communication: in the classic model, the server can only respond, never notify. For "you received a message" or "your order was executed," the client has to keep asking.

How it works

WebSocket — persistent full-duplex connection

WebSocket starts with an HTTP handshake: the client sends a request with Upgrade: websocket and the server responds with 101 Switching Protocols. From then on, the TCP connection stays open and both sides can send frames in either direction, at any time, without per-message HTTP header overhead.

// HTTP handshake → ws://
GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

// From here on: binary or text frames in both directions
// with no HTTP overhead per message

The WebSocket frame has minimal overhead: 2–10 bytes of header versus 200–800 bytes of HTTP headers. In high-frequency applications (trading, games), that difference is significant.

Scalability: WebSocket connections are stateful. The same client must always reach the same server instance (sticky session), or the server must use an external broker (Redis Pub/Sub, Kafka) to propagate messages between instances — broadcasting only works within a single instance without external coordination.

SSE — Server-Sent Events

SSE uses plain HTTP: the client makes a GET and the server responds with Content-Type: text/event-stream, keeping the connection open and sending events in the format data: ...\n\n as they arise. It's unidirectional: only the server sends data; the client uses the browser's native EventSource API.

// Server sends events in SSE format
data: {"type":"price","value":142.5}\n\n
data: {"type":"price","value":143.1}\n\n
: keepalive comment (avoids proxy timeouts)\n\n
data: {"type":"alert","msg":"target reached"}\n\n

Advantages of SSE over WebSocket for unidirectional cases:

  • Automatic reconnection: if the connection drops, the browser reopens it automatically and sends the last received ID via Last-Event-ID, allowing the server to resume from the correct point.
  • HTTP/2 multiplexed: over HTTP/2, multiple SSE streams share a single TCP connection, eliminating HTTP/1.1's 6-parallel-connection limit.
  • Simpler to implement and debug: it's plain HTTP, visible in the browser's network tools, compatible with any proxy, and with no special handshake.

Long Polling — server push via repeated pull

Long Polling simulates server push without a specialized protocol: the browser makes a normal HTTP request, but the server doesn't respond immediately — it holds the connection open until it has data to send (or until a configured timeout). When it responds, the browser immediately opens a new request to wait for the next update.

It's the approach with the highest overhead (full HTTP headers every cycle) and highest latency (latency = server response time + time to establish a new request), but it works through any corporate proxy and any restricted network environment, which makes it a reliable fallback.

WebSocket Browser Server GET (Upgrade: websocket) 101 Switching Protocols ws:// ACTIVE frame → {msg: "hi"} frame ← {broadcast} frame → {ping} frame ← {pong} Full-duplex · Stateful sticky session or Redis Pub/Sub Chat · Games · Collaboration · Trading SSE (Server-Sent Events) Browser Server GET /events 200 text/event-stream (stream open) data: {"price":142.5} data: {"price":143.1} : keepalive data: {"alert":"done"} One-way: server → browser Automatic reconnection · EventSource API HTTP/2: multiple streams on one connection Notifications · Feeds · Progress · Dashboard Long Polling Browser Server GET /poll waiting... 200 OK {event: data} GET /poll (immediate retry) 204 No Content (timeout) GET /poll... HTTP overhead per cycle · Higher latency Works with any corporate proxy Fallback when SSE/WS isn't viable Fallback · Restricted environments · Maximum compatibility
Sequence comparison between WebSocket (full-duplex, persistent connection), SSE (server to browser, plain HTTP with automatic reconnection), and Long Polling (repeated pull that simulates server push without a special protocol).

Comparison table

                  WebSocket       SSE               Long Polling
  ────────────────────────────────────────────────────────────────
  Protocol        ws:// / wss://  HTTP              HTTP
  Direction       Full-duplex     Server → client   Server → client
  Overhead/msg    2–10 bytes      ~50 bytes         200–800 bytes
  Auto reconnect  No (manual)     Yes (native)      No (manual)
  HTTP/2          Not compatible  Compatible        Compatible
  Corp. proxies   May block       Usually OK        Always works
  Complexity      Medium          Low               Low
  ────────────────────────────────────────────────────────────────
  Ideal for       Chat, games,    Notifications,    Fallback,
                  collaboration,  feeds, progress,  restricted
                  trading, IoT    live dashboards   corporate envs

WebSocket scalability with multiple instances

WebSocket creates stateful connections: if user A is connected to instance 1 and user B is on instance 2, a message from user B to user A doesn't arrive by default — instance 1's server doesn't know instance 2 has something to deliver.

The two standard solutions:

  • Sticky session: the load balancer guarantees the same client always reaches the same instance. Simple, but reduces load balancing effectiveness and complicates zero-downtime deploys.
  • External Pub/Sub: each instance publishes messages to a broker (Redis Pub/Sub, Kafka, NATS) and subscribes to the topics of its connected clients. Any instance can receive a message and deliver it to the correct client. More robust and scalable, but more complex to operate.

When to use each protocol

  • WebSocket: when there's genuinely bidirectional real-time communication — chat, collaborative editing, multiplayer games, trading dashboards where the client also sends data frequently (cursor position, trading commands). Minimal latency is essential.
  • SSE: when the server needs to send updates to the client but the client doesn't continuously send data back. Notifications, activity feeds, job progress bars, monitoring dashboards. Prefer SSE over WebSocket in these cases — it's simpler and takes advantage of HTTP/2.
  • Long Polling: use as a fallback when SSE doesn't work (corporate proxies that buffer responses, environments with old support). Many libraries (Socket.IO, SignalR) implement Long Polling as an automatic fallback.

When to avoid

  • WebSocket when SSE would suffice: WebSocket has more operational overhead, requires sticky sessions or pub/sub, and doesn't take advantage of HTTP/2. For unidirectional flow, SSE is the simpler, more robust choice.
  • WebSocket for 1M+ simultaneous connections without a dedicated architecture: each connection keeps state on the server. At high scale, this requires dedicated gateway servers (like Pushpin, Fanout, or specific event-driven architectures), not conventional application servers.

Pros and cons

WebSocket — Pros

  • Real full-duplex: both sides send data without waiting for the other to finish.
  • Minimal overhead per message (2–10 bytes vs. 200+ for HTTP headers).
  • Lower latency than any HTTP-based solution.
  • Natively supported in all modern browsers and most frameworks.

WebSocket — Cons

  • Stateful: requires sticky sessions or external pub/sub for multiple instances.
  • Not compatible with HTTP/2 multiplexing.
  • Some corporate proxies block or inspect ws:// — always use wss://.
  • Reconnection needs to be implemented manually on the client.

SSE — Pros

  • Plain HTTP: transparent to proxies, load balancers, and network tools.
  • Native automatic reconnection via EventSource + Last-Event-ID.
  • Takes advantage of HTTP/2: multiple streams on a single TCP connection.
  • Simpler to implement and debug than WebSocket.

SSE — Cons

  • Unidirectional: the client can't send data over the SSE stream — needs separate HTTP requests.
  • Some old proxies buffer the response, breaking streaming (mitigable with keepalive comments).

Common pitfalls

1. WebSocket without sticky session and without pub/sub

The most common mistake in horizontal deploys: user A connects to instance 1, user B connects to instance 2, and when B sends a message to A, instance 2 tries to deliver it — but A isn't connected there. The message is silently lost. The symptom is "sometimes messages don't arrive," and it gets worse exactly when there's more traffic (and therefore more instances).

Solution: before scaling horizontally, implement Redis Pub/Sub or equivalent. Each instance publishes messages to the recipient's channel, and every instance subscribes to all the channels of its connected clients.

2. ws:// without TLS in production

ws:// connections (without TLS) are transparent to intermediary proxies, which can inject content, modify frames, or simply block the connection. In production, always use wss:// (WebSocket over TLS), which is treated as HTTPS by proxies.

3. SSE blocked by corporate proxies

Some corporate proxies buffer HTTP responses before passing them to the client, which breaks SSE streaming (the client never receives intermediate events, only the final one). The mitigation is to send periodic comments (: keepalive\n\n) every 15–30 seconds, which force a flush in many proxies. If that doesn't solve it, Long Polling is the fallback.

4. Underestimated connection scale

10,000 simultaneous WebSocket connections are perfectly manageable on a well-configured node.js server. 1,000,000 connections is a completely different architecture: it requires dedicated gateway servers, kernel tuning (ulimit, net.core.somaxconn), and possibly managed services (AWS API Gateway WebSocket, Ably, Pusher). Don't treat 10k and 1M as the same problem.

Related architectures and patterns

Message Queues complement WebSocket at the backend layer: the websocket server receives messages from clients, publishes them to a queue, and processes them asynchronously, decoupling the connection layer from the processing layer.

Load Balancing is directly affected by the protocol choice: WebSocket requires sticky sessions or balancing algorithms that respect session affinity (IP hash, cookie-based), while SSE works with any balancing algorithm, including pure round-robin.

REST vs GraphQL vs gRPC covers synchronous request-response protocols. WebSocket and SSE are complementary to them — many architectures use REST for CRUD operations and WebSocket/SSE for real-time updates.