Real-time features (chat, presence, notifications, live data) are no longer optional in 2026. FastAPI’s WebSocket support is solid for production. This post is the working playbook.

A connection manager

from collections import defaultdict
from typing import Set
from fastapi import WebSocket


class ConnectionManager:
    def __init__(self):
        self.connections: dict[int, Set[WebSocket]] = defaultdict(set)

    async def connect(self, user_id: int, ws: WebSocket):
        await ws.accept()
        self.connections[user_id].add(ws)

    def disconnect(self, user_id: int, ws: WebSocket):
        self.connections[user_id].discard(ws)
        if not self.connections[user_id]:
            del self.connections[user_id]

    async def send_to(self, user_id: int, message: dict):
        for ws in self.connections.get(user_id, ()):
            try:
                await ws.send_json(message)
            except RuntimeError:
                pass        # connection closed; will be cleaned up on disconnect


manager = ConnectionManager()

A simple pattern. Tracks connections per user. Send to a user routes to all their devices.

Auth at connect

@app.websocket("/ws")
async def ws_endpoint(websocket: WebSocket, token: str = Query(...)):
    user = await verify_token(token)
    if not user:
        await websocket.close(code=4401, reason="unauthorized")
        return
    await manager.connect(user.id, websocket)
    try:
        while True:
            data = await websocket.receive_json()
            await handle_message(user, data)
    except WebSocketDisconnect:
        manager.disconnect(user.id, websocket)

Token in query string is logged everywhere — fine for short-lived tokens, problematic for long-lived. For mobile / SPA clients, prefer:

@app.websocket("/ws")
async def ws(websocket: WebSocket):
    await websocket.accept()
    auth_msg = await asyncio.wait_for(websocket.receive_json(), timeout=5)
    user = await verify_token(auth_msg.get("token"))
    if not user:
        await websocket.close(4401)
        return
    # ... continue

First message is auth; close on failure.

Heartbeats and dead connections

Browsers can disappear silently (laptop closed, network drop). Detect:

async def heartbeat_loop(websocket: WebSocket):
    while True:
        await asyncio.sleep(30)
        try:
            await websocket.send_json({"type": "ping"})
        except RuntimeError:
            return  # connection closed

Or rely on the client sending pings; close after 90s without one.

Cross-process broadcast

A FastAPI process can hold ~10–50k WebSockets. For more, run multiple processes / pods. But each process only knows its own connections — to broadcast, you need a backplane.

Redis Pub/Sub

import redis.asyncio as redis

r = redis.Redis()
pubsub = r.pubsub()


async def listen_for_broadcast():
    await pubsub.subscribe("broadcast")
    async for msg in pubsub.listen():
        if msg["type"] == "message":
            data = json.loads(msg["data"])
            await manager.send_to(data["user_id"], data["payload"])


async def broadcast_to_user(user_id: int, payload: dict):
    await r.publish("broadcast", json.dumps({"user_id": user_id, "payload": payload}))

Every process subscribes; every broadcast publishes. Each process delivers to its own connections.

NATS for higher scale

For tens of millions of connections, NATS handles the broadcast cleaner. See Kafka vs NATS vs RabbitMQ .

Sticky routing

For very high scale, hash users to specific processes so a user’s connection always lands on the same node. Then broadcast only needs to find the right node, not ask all of them. See Design WhatsApp / Chat .

Performance notes

  • Run with uvloop: pip install 'uvicorn[standard]' enables it automatically.
  • Tune ulimit -n to match expected connections.
  • Set --workers 1 per process; scale horizontally.
  • For 10k+ connections per process, monitor file descriptor use.

Common mistakes

1. Auth in the query string for long-lived tokens

Logged in proxies, browser history, server logs. Use first-message auth for sensitive tokens.

2. Sync work inside the WebSocket handler

A blocking call freezes everything. All work async; use TaskGroup for parallel async.

3. No heartbeat

Half-open connections accumulate. Keep memory growing. Eventual OOM.

4. Single-process design

A single process can’t grow forever. Plan for multi-process from day one.

5. Forgetting to clean up

Disconnect events sometimes don’t fire (process kill). Periodic sweep of stale connections.

SSE vs WebSocket

For server-only push, SSE is simpler. WebSockets earn complexity when bidirectional or low-latency mid-stream control matters. See SSE vs WebSockets in 2026 .

Read this next

If you want a FastAPI WebSocket starter with Redis broadcast and auth, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .