Skip to main content
All posts

Building Real-Time Features with React, FastAPI, and WebSockets

16 June 2026

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:

  1. 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.
  2. Browser connection limits killed scalability. SSE uses one connection per client. With 50+ users in a single room, browser limits bite hard.
  3. 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:

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:

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'
        }`} />
You might also like

Comments

All comments are moderated before appearing.

Loading comments…

Leave a comment

0/2000

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch