Building Real-Time Features with React, FastAPI, and WebSockets
Real-time features are table stakes for modern SaaS. Users expect live notifications, collaborative editing, and instant updates—not a refresh button. But implementing this across React and FastAPI is where most teams stumble.
I’ve built this stack three times now. Twice, I did it wrong. Here’s what actually works, and why I prefer this approach over alternatives like Socket.IO or cloud-hosted message queues for early-stage SaaS.
Why WebSockets Over Polling or Server-Sent Events
I used to reach for Server-Sent Events (SSE) because “it’s simpler.” It burned me when:
- One-way communication became a bottleneck. Users sending real-time commands (collaborative cursor positions, live form updates) required a separate POST endpoint. Now you’re managing state across two channels.
- Browser connection limits killed scalability. SSE uses one connection per client. With 50+ users in a single room, browser limits bite hard.
- Mobile reconnection logic was fragile. The automatic retry mechanism in SSE doesn’t handle auth token refresh gracefully.
WebSockets aren’t “overkill”—they’re the right tool when you need bidirectional, low-latency communication. Yes, they’re slightly more complex. But you get deterministic behavior and one mental model for all real-time messaging.
Socket.IO? I avoid it. The abstraction overhead (fallbacks, heartbeats, reconnection) adds 40KB to your bundle. For SaaS where you control both client and server, raw WebSockets with clean error handling win.
The Architecture
Here’s the mental model I use:
- FastAPI WebSocket manager holds active connections grouped by “rooms” (a tenant, document, or collaborative workspace)
- React hook manages connection state, reconnection, and message queueing
- Authentication happens at handshake, not per-message
- Message routing is explicit: the backend knows what events each room cares about
This keeps your backend stateless and testable.
FastAPI Backend: Connection Manager
# websocket_manager.py
from fastapi import WebSocket, WebSocketDisconnect
from typing import Dict, List, Set
import json
import asyncio
from datetime import datetime
class ConnectionManager:
def __init__(self):
# room_id -> set of WebSocket connections
self.active_connections: Dict[str, List[WebSocket]] = {}
self.user_rooms: Dict[WebSocket, str] = {}
async def connect(self, room_id: str, websocket: WebSocket):
await websocket.accept()
if room_id not in self.active_connections:
self.active_connections[room_id] = []
self.active_connections[room_id].append(websocket)
self.user_rooms[websocket] = room_id
async def disconnect(self, websocket: WebSocket):
room_id = self.user_rooms.get(websocket)
if room_id and websocket in self.active_connections[room_id]:
self.active_connections[room_id].remove(websocket)
if not self.active_connections[room_id]:
del self.active_connections[room_id]
self.user_rooms.pop(websocket, None)
async def broadcast(self, room_id: str, message: dict, exclude: WebSocket = None):
"""Send message to all clients in a room."""
if room_id not in self.active_connections:
return
dead_connections = []
for connection in self.active_connections[room_id]:
if connection is exclude:
continue
try:
await connection.send_json(message)
except Exception:
dead_connections.append(connection)
# Clean up dead connections
for conn in dead_connections:
await self.disconnect(conn)
manager = ConnectionManager()
# In your FastAPI app
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Query
from fastapi.security import HTTPBearer
import jwt
app = FastAPI()
security = HTTPBearer()
async def verify_ws_token(token: str) -> dict:
"""Verify JWT token from WebSocket query params."""
try:
payload = jwt.decode(token, "your-secret", algorithms=["HS256"])
return payload
except jwt.InvalidTokenError:
return None
@app.websocket("/ws/{room_id}")
async def websocket_endpoint(
websocket: WebSocket,
room_id: str,
token: str = Query(...)
):
# Authenticate before accepting connection
user = await verify_ws_token(token)
if not user:
await websocket.close(code=4001, reason="Unauthorized")
return
await manager.connect(room_id, websocket)
try:
while True:
data = await websocket.receive_json()
# Broadcast to all users in room
await manager.broadcast(
room_id,
{
"type": data.get("type"),
"user_id": user["sub"],
"payload": data.get("payload"),
"timestamp": datetime.utcnow().isoformat()
},
exclude=websocket # Don't echo back to sender
)
except WebSocketDisconnect:
await manager.disconnect(websocket)
Why this structure: Each room is isolated. You can horizontally scale by adding a Redis pub/sub layer later (for multiple server instances), but this works perfectly for one server handling thousands of concurrent users.
React Hook: useWebSocket
// hooks/useWebSocket.ts
import { useEffect, useRef, useCallback, useState } from 'react';
interface WebSocketMessage {
type: string;
user_id: string;
payload: any;
timestamp: string;
}
export const useWebSocket = (
roomId: string,
token: string,
onMessage: (msg: WebSocketMessage) => void,
wsUrl = import.meta.env.PUBLIC_WS_URL
) => {
const ws = useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const messageQueue = useRef<any[]>([]);
const reconnectAttempts = useRef(0);
const maxReconnectAttempts = 5;
const connect = useCallback(() => {
if (ws.current?.readyState === WebSocket.OPEN) return;
const url = `${wsUrl}/ws/${roomId}?token=${token}`;
ws.current = new WebSocket(url);
ws.current.onopen = () => {
setIsConnected(true);
reconnectAttempts.current = 0;
// Flush queued messages
while (messageQueue.current.length > 0) {
const msg = messageQueue.current.shift();
ws.current?.send(JSON.stringify(msg));
}
};
ws.current.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as WebSocketMessage;
onMessage(message);
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
ws.current.onerror = (error) => {
console.error('WebSocket error:', error);
setIsConnected(false);
};
ws.current.onclose = () => {
setIsConnected(false);
// Exponential backoff reconnection
if (reconnectAttempts.current < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
reconnectAttempts.current++;
setTimeout(connect, delay);
}
};
}, [roomId, token, onMessage, wsUrl]);
useEffect(() => {
connect();
return () => ws.current?.close();
}, [connect]);
const send = useCallback((message: any) => {
if (ws.current?.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(message));
} else {
// Queue message if not connected
messageQueue.current.push(message);
}
}, []);
return { isConnected, send };
};
Why I structured it this way:
- Message queueing ensures no data loss during reconnection
- Exponential backoff prevents hammering your server if auth fails
- State is granular so you can render connection status separately from app state
Using It: A Collaborative Todo List
// components/TodoRoom.tsx
import { useWebSocket } from '../hooks/useWebSocket';
import { useAuth } from '../hooks/useAuth';
import { useState } from 'react';
export const TodoRoom = ({ roomId }: { roomId: string }) => {
const { token } = useAuth();
const [todos, setTodos] = useState<any[]>([]);
const { isConnected, send } = useWebSocket(
roomId,
token,
(message) => {
if (message.type === 'todo_created') {
setTodos(prev => [...prev, message.payload]);
}
if (message.type === 'todo_deleted') {
setTodos(prev =>
prev.filter(t => t.id !== message.payload.id)
);
}
}
);
const createTodo = (title: string) => {
send({
type: 'todo_created',
payload: { title, created_at: new Date().toISOString() }
});
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
isConnected ? 'bg-green-500' : 'bg-red-500'
}`} />
Comments
All comments are moderated before appearing.
Leave a comment