System DesignIntermediate 13 min Lesson 18 of 42

WebSockets — Two-Way Connections

When the server needs to push data to the client, and what changes in your architecture when connections stay open.

System Design · Lesson 18 of 42
0/42 done(0%)

What is it? #

A WebSocket is a connection that stays open, letting both sides send messages whenever they want.

Ordinary HTTP is one-way in initiation: the client asks, the server answers. For chat, live dashboards, notifications and collaborative editing, the server needs to speak first.

Before WebSockets, the workaround was polling — asking every few seconds. That wastes requests, adds delay, and scales badly.

The cost of an open connection is state. Each connection belongs to a specific server, which complicates load balancing, deployments and scaling in ways that ordinary HTTP does not.

Think of it like this #

Polling is phoning the shop every two minutes to ask whether your order has arrived. Most calls are wasted, and you still hear about it up to two minutes late.

A WebSocket is leaving the line open. The shop tells you the moment it arrives. Better for both sides, as long as the shop can hold that many lines open.

Simple example #

A support chat needs messages to appear instantly for both sides, plus typing indicators and presence. Polling every second would be wasteful and still feel sluggish.

Code #

TEXT
Choosing the right tool

polling             simple, works everywhere, wasteful and delayed
long polling        fewer wasted requests, still request-shaped
server-sent events  server → client only, automatic reconnect, HTTP-based
WebSocket           both directions, lowest latency, most operational complexity

If the client never needs to push, server-sent events are often enough.
PYTHON
# FastAPI WebSocket endpoint with a simple connection registry
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from collections import defaultdict

app = FastAPI()
rooms: dict[str, set[WebSocket]] = defaultdict(set)


@app.websocket("/ws/rooms/{room_id}")
async def chat(websocket: WebSocket, room_id: str):
    await websocket.accept()
    rooms[room_id].add(websocket)
    try:
        while True:
            message = await websocket.receive_json()
            await broadcast(room_id, {"from": message["user"], "text": message["text"]})
    except WebSocketDisconnect:
        pass
    finally:
        rooms[room_id].discard(websocket)          # always clean up


async def broadcast(room_id: str, payload: dict) -> None:
    dead = []
    for connection in rooms[room_id]:
        try:
            await connection.send_json(payload)
        except Exception:
            dead.append(connection)
    for connection in dead:
        rooms[room_id].discard(connection)
JAVASCRIPT
// Client side: reconnect, because connections drop
function connect(roomId) {
  const socket = new WebSocket(`wss://example.com/ws/rooms/${roomId}`);

  socket.onmessage = (event) => render(JSON.parse(event.data));

  socket.onclose = () => {
    setTimeout(() => connect(roomId), backoffMs());   // reconnect with backoff
  };

  return socket;
}
TEXT
Scaling across several servers

Each connection lives on ONE server. A message from a user on server A
must reach a user connected to server B.

  user A ──▶ server A ──▶ Redis pub/sub ──▶ server B ──▶ user B

Without that shared channel, users only see messages from people who
happen to be connected to the same instance.

How it works #

The connection starts as an HTTP request with an upgrade header and then switches protocol. That is why your reverse proxy needs the upgrade configuration covered in the reverse proxy lesson.

await websocket.accept() completes the handshake. After that, both sides can send at any time.

The rooms registry maps a room to its open connections. It lives in the memory of one process, which is exactly the limitation that makes scaling harder.

The finally block removes the connection on any exit path. Forgetting this leaks connection objects and eventually memory.

Broadcasting collects failed sends and cleans them up. A connection can die without a clean disconnect event, so sending is how you often discover it.

The client reconnects on close, with backoff. Connections drop constantly in reality — mobile networks, laptop sleep, proxy timeouts — so reconnection is not an edge case.

The scaling diagram is the key architectural point. With more than one server, a shared channel such as Redis pub/sub is required so messages reach users connected elsewhere.

Real-world use #

Chat, live dashboards, notifications, collaborative editing, multiplayer games, live sports scores and trading interfaces all use persistent connections.

Managed services exist precisely because the operational side is awkward. Pusher, Ably and cloud WebSocket gateways handle connection state, fan-out and scaling for you.

Deployments need thought. Restarting a server disconnects every client on it, so they all reconnect at once — a thundering herd. Staggered restarts and client-side jittered backoff prevent the reconnect storm from overwhelming the remaining servers.

Connection count is the metric to watch, along with message rate and reconnection rate. Each connection consumes memory and a file descriptor, so operating system limits matter at scale.

For many features, server-sent events are the better answer: one-way, simpler, automatic reconnection, and they work over plain HTTP.

Common mistakes #

  • Using WebSockets when server-sent events or plain polling would be sufficient.
  • Keeping connection state in one process without a shared channel between servers.
  • Forgetting to remove connections on disconnect, leaking memory.
  • No client-side reconnection with backoff, so a brief drop ends the session.
  • Missing upgrade headers and long timeouts in the reverse proxy configuration.

Practice #

Build a small chat endpoint that broadcasts to a room. Add client reconnection with exponential backoff, then describe in two sentences what you would change to run it across three servers.

Quick quiz

  1. 1. What does a WebSocket add over ordinary HTTP?

  2. 2. When are server-sent events a better choice?

  3. 3. Why is scaling WebSockets harder than scaling HTTP?

  4. 4. Why must clients reconnect with backoff?

  5. 5. What must a reverse proxy be configured for?

Summary

  • WebSockets keep a connection open so the server can push messages.
  • Server-sent events are simpler when only the server needs to send.
  • Connections are tied to one server, so scaling needs a shared pub/sub channel.
  • Always clean up on disconnect and reconnect with jittered backoff.
  • Watch connection counts, and configure your proxy for upgrades.