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 #
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.
# 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)
// 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;
}
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.