WebSockets

advanced fastapi websockets realtime production

WebSockets are persistent two-way connections between client and server. Unlike HTTP (request → response → done), a WebSocket stays open and both sides can send messages whenever. Think of it like a phone call vs sending letters — once the line is open, anyone can talk.

FastAPI (well, Starlette under the hood) makes WebSockets feel as natural as regular routes.

The basic route

from fastapi import FastAPI, WebSocket, WebSocketDisconnect

app = FastAPI()

@app.websocket("/ws/echo")
async def echo(ws: WebSocket):
    await ws.accept()
    try:
        while True:
            msg = await ws.receive_text()
            await ws.send_text(f"echo: {msg}")
    except WebSocketDisconnect:
        print("client disconnected")

Three things to notice:

  1. @app.websocket(...) not @app.get(...).
  2. Must call await ws.accept() to complete the handshake.
  3. Always wrap the loop in try/except WebSocketDisconnect — otherwise we log noisy tracebacks.

Connection lifecycle

WebSocket lifecycle
Client
HTTP Upgrade →
Server
  <div style="border: 1px solid var(--color-border); padding: 6px; border-radius: 4px; text-align: center;">Client</div>
  <div style="text-align: center; color: var(--color-tag-beginner);">← 101 Switching Protocols<br/>(ws.accept)</div>
  <div style="border: 1px solid var(--color-border); padding: 6px; border-radius: 4px; text-align: center;">Server</div>

  <div style="border: 1px solid var(--color-border); padding: 6px; border-radius: 4px; text-align: center;">Client</div>
  <div style="text-align: center; color: var(--color-accent);">⇄ send / receive loop ⇄<br/>(text, json, bytes)</div>
  <div style="border: 1px solid var(--color-border); padding: 6px; border-radius: 4px; text-align: center;">Server</div>

  <div style="border: 1px solid var(--color-border); padding: 6px; border-radius: 4px; text-align: center;">Client</div>
  <div style="text-align: center; color: var(--color-tag-advanced);">close frame<br/>(WebSocketDisconnect)</div>
  <div style="border: 1px solid var(--color-border); padding: 6px; border-radius: 4px; text-align: center;">Server</div>
</div>

Sending and receiving — text, JSON, bytes

await ws.send_text("hello")
await ws.send_json({"event": "tick", "value": 42})
await ws.send_bytes(b"\x00\x01\x02")

text = await ws.receive_text()
data = await ws.receive_json()    # parses for us
buf = await ws.receive_bytes()

receive_json raises WebSocketDisconnect on disconnect too — handle it.

Broadcasting — the connection manager pattern

A chat room or notifications system needs to push a single message to many connected clients. The standard pattern: a ConnectionManager that tracks active sockets.

from fastapi import FastAPI, WebSocket, WebSocketDisconnect

class ConnectionManager:
    def __init__(self):
        self.active: list[WebSocket] = []

    async def connect(self, ws: WebSocket):
        await ws.accept()
        self.active.append(ws)

    def disconnect(self, ws: WebSocket):
        self.active.remove(ws)

    async def broadcast(self, message: str):
        # iterate over a copy — disconnects mutate the list
        for conn in list(self.active):
            try:
                await conn.send_text(message)
            except Exception:
                self.disconnect(conn)

manager = ConnectionManager()
app = FastAPI()

@app.websocket("/ws/chat/{username}")
async def chat(ws: WebSocket, username: str):
    await manager.connect(ws)
    await manager.broadcast(f"-> {username} joined")
    try:
        while True:
            msg = await ws.receive_text()
            await manager.broadcast(f"{username}: {msg}")
    except WebSocketDisconnect:
        manager.disconnect(ws)
        await manager.broadcast(f"<- {username} left")

This is great for a single-process app. The moment we run multiple workers (e.g., uvicorn with --workers 4), each worker has its own active list and broadcasts only reach clients on the same worker. Fix: use Redis pub/sub (or NATS, RabbitMQ) so all workers see all messages.

# sketch — each worker subscribes to a Redis channel
async def listen_redis():
    pubsub = redis.pubsub()
    await pubsub.subscribe("chat")
    async for msg in pubsub.listen():
        if msg["type"] == "message":
            await manager.broadcast(msg["data"].decode())

Auth on a WebSocket

Browsers can’t set custom headers on the WS handshake from JavaScript. Workarounds:

  1. Query param tokenwss://api.com/ws?token=... — easy, but tokens leak into logs.
  2. First message handshake — connect anonymously, then send {"type":"auth","token":"..."} as the first message. Reject if missing.
  3. Cookie-based — works if same-origin and the client has an auth cookie.
from fastapi import Query, WebSocket, status

@app.websocket("/ws")
async def secured(ws: WebSocket, token: str = Query(...)):
    user = decode_jwt(token)
    if not user:
        await ws.close(code=status.WS_1008_POLICY_VIOLATION)
        return
    await ws.accept()
    ...

WebSockets vs Server-Sent Events (SSE)

SSE is a much simpler “server pushes events to client over HTTP” mechanism. It’s one-directional (server → client only), runs over plain HTTP/1.1, and auto-reconnects in the browser.

WebSocketSSE
DirectionBidirectionalServer → Client only
Protocolws:// / wss:// (upgrade)Plain HTTP
Auto-reconnectManualBuilt-in
Browser supportUniversalUniversal except IE
Proxy/CDN friendlySometimes flakyJust HTTP, works everywhere
Custom headersNo (from JS)Yes
Binary dataYesNo (UTF-8 text)

Rule of thumb:

  • Need client → server too? WebSocket. Chat, multiplayer, collaborative editing.
  • Only need server → client? SSE. Notifications, live dashboards, AI streaming responses. SSE is way simpler and survives proxies better. Most LLM streaming APIs use SSE for a reason.

Production gotchas

  • Idle timeouts. Load balancers (AWS ELB, Cloudflare) close idle connections after ~60s. Implement app-level ping/pong, or use Starlette’s built-in keep-alive.
  • Backpressure. If a slow client can’t keep up, our send calls queue up in memory. Cap message rate or drop messages.
  • Sticky sessions or pub/sub. Multi-worker setups need one or the other (or both).
  • wss:// in prod. Always TLS. Mixed content rules block ws:// from HTTPS pages.

Interview cheat sheet

  • @app.websocket("/path") + await ws.accept() + loop on receive_*.
  • Catch WebSocketDisconnect to clean up.
  • Connection manager pattern for broadcasts; Redis pub/sub once we go multi-worker.
  • Auth via query token or first-message handshake (no custom headers from browser JS).
  • SSE is the simpler alternative for one-way streams — prefer it when you don’t need client → server messages.