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 -nto match expected connections. - Set
--workers 1per 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
- SSE vs WebSockets in 2026
- Modern AsyncIO Patterns
- Design WhatsApp / Chat
- FastAPI + Pydantic v2 + SQLAlchemy 2.0
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 .