Engineering in practice

WebSockets in FastAPI without magic

13 October 2026 · 8 min read

Real-Time is More Than an Echo Route

Almost every tutorial on WebSockets in FastAPI starts and ends with a simplistic echo route:

# A toy implementation: echoes text, lacks authentication, leaks memory
@app.websocket("/ws")
async def echo_endpoint(websocket: WebSocket):
    await websocket.accept()
    while True:
        data = await websocket.receive_text()
        await websocket.send_text(f"Echo: {data}")

In production, real-time messaging is vastly more nuanced:

  • How do you verify client identity safely without query param leaks?
  • How do you clean up ghost connections when mobile networks drop abruptly?
  • How do you broadcast events across horizontally scaled container replicas without maintaining state in a single worker’s memory?

1. Authentication: Never Pass Tokens in Query Parameters

A common mistake is authenticating WebSockets via URLs: /ws?token=eyJhbGci....

Query strings are logged in plaintext by Nginx reverse proxies, cloud load balancers, and CDN access logs. Anyone with access to system telemetry can steal customer bearer tokens.

The Secure Alternative: First-Message Handshake

Accept the WebSocket connection and immediately await a strongly-typed authentication payload with a short timeout. If valid, proceed; otherwise, close with an RFC-compliant policy violation code (1008):

@app.websocket("/ws/notifications")
async def notifications_ws(websocket: WebSocket):
    await websocket.accept()
    try:
        # Await auth frame within 5 seconds
        auth_message = await asyncio.wait_for(websocket.receive_json(), timeout=5.0)
        token = auth_message.get("token")
        user = await verify_jwt_token(token)
        if not user:
            await websocket.close(code=1008, reason="Unauthorized")
            return
    except asyncio.TimeoutError:
        await websocket.close(code=1008, reason="Authentication timeout")
        return

    await manager.subscribe(user.id, websocket)
    try:
        while True:
            await websocket.receive_text()
    except WebSocketDisconnect:
        manager.unsubscribe(user.id, websocket)

2. Preventing Memory Leaks with Cleanup Blocks

Mobile devices lose Wi-Fi signal, laptops close, and browser tabs crash without cleanly completing the TCP closure handshake.

If an exception occurs while transmitting to a dead socket, your connection manager must guarantee that dead sockets are pruned:

class ConnectionManager:
    def __init__(self):
        self.active_connections: dict[str, set[WebSocket]] = defaultdict(set)

    async def broadcast_to_user(self, user_id: str, message: dict):
        dead_sockets = set()
        for ws in self.active_connections.get(user_id, []):
            try:
                await ws.send_json(message)
            except Exception:
                dead_sockets.add(ws)

        for dead_ws in dead_sockets:
            self.unsubscribe(user_id, dead_ws)

3. Scaling Across Multiple Workers: Redis Pub/Sub Backbone

If your FastAPI application runs across four Uvicorn workers or ten Kubernetes pods, in-memory sets cannot share events across processes.

Use a Redis Pub/Sub channel as the shared messaging backbone:

# Publishing an event from any REST endpoint or worker
async def notify_user(user_id: str, payload: dict):
    await redis.publish(f"events:user:{user_id}", json.dumps(payload))

# Background listener running on each container instance
async def redis_listener(manager: ConnectionManager):
    pubsub = redis.pubsub()
    await pubsub.psubscribe("events:user:*")
    async for message in pubsub.listen():
        if message["type"] == "pmessage":
            channel = message["channel"].decode()
            user_id = channel.split(":")[-1]
            data = json.loads(message["data"])
            await manager.broadcast_to_user(user_id, data)

Every container listens to Redis and relays events strictly to the WebSocket clients currently connected to its own local process. This architecture scales linearly with zero cross-worker locks.