Back to Blog
Real-Time WebSockets Backend FastAPI

Scalable WebSockets: Build Real-Time Systems

• 10 min read • Written by Marlon

TL;DR:

  • The Trap: Naive in-memory WebSocket managers (active_connections: list) lack horizontal distribution across replicas, leak megabytes per idle half-open socket, and crash via Out-Of-Memory (OOM) exceptions when slow mobile clients stall backend event loops.
  • The Architecture: A distributed 4-stage pipeline combining ephemeral single-use ticket handshakes, isolated connection pools with bounded per-socket queues (backpressure), an in-memory Redis Pub/Sub event backplane, and aggressive binary ping/pong heartbeats.
  • The Business Impact: Supports 100,000+ concurrent stateful sockets per node with sub-50ms broadcast latencies, reducing cloud CPU utilization by over 80% compared to legacy short-polling architectures.

The Polling Tax: How Repetitive HTTP Requests Drain Your Cloud Budget

Simulating real-time interactivity by forcing the client browser to poll your REST API every two to three seconds (HTTP short-polling) is the most expensive mistake an early-stage engineering team can make.

Every standard HTTP request transmits cookies, TLS negotiation envelopes, and security headers across the wire. When 5,000 active clients poll concurrently, your servers process 150,000 requests every minute—over 98% of which return HTTP 304 or an empty JSON payload because no state change occurred.

This redundant network thrashing exhausts database connection pools, saturates web server worker threads, and artificially inflates your AWS or GCP infrastructure invoices.

To support high-density real-time interactivity—such as collaborative documents, financial trading charts, sports telemetries, or live operational dashboards—systems must migrate to persistent, bidirectional full-duplex TCP channels via WebSockets (RFC 6455).

However, moving from stateless HTTP to stateful WebSockets introduces fundamental distributed systems challenges: socket memory leakage, thundering herd reconnections, and state synchronization across multi-node server clusters.

  1. STAGE 01 <20ms

    Ephemeral Ticket Auth

    Exchanges user session for a 30-second single-use cryptographic token verified before upgrading the HTTP connection.

  2. STAGE 02 Local Node

    Bounded Connection Pool

    Manages sockets in memory with dedicated bounded asyncio queues to enforce backpressure on slow consumers.

  3. STAGE 03 <2ms Pub/Sub

    Redis Event Backplane

    Broadcasts channel events across all backend nodes, decoupling physical socket residence from business logic.

  4. STAGE 04 Garbage Coll.

    Adaptive Ping/Pong

    Dispatches binary heartbeats every 30 seconds; forcibly tears down half-open zombie connections.


Architectural Deep Dive: Escaping the Single-Node Trap

1. The Distributed State Dilemma

Unlike stateless REST endpoints where any server replica can process any incoming payload, WebSockets are stateful: client $A$ terminates its physical TCP socket in the memory heap of Node 1, while client $B$ terminates in Node 2.

When a message is published for a shared workspace or channel, Node 1 cannot write bytes into Node 2’s TCP sockets directly.

The production pattern to bridge this boundary is an In-Memory Event Backplane using Redis Pub/Sub:

  • Each server node maintains a single, persistent multiplexed connection to a central Redis instance or Redis cluster.
  • Nodes subscribe to partitioned channel patterns matching their locally active rooms (workspace:{id}:updates).
  • When any node receives a mutation, it publishes the event to Redis in under 2ms.
  • Every node holding active subscribers receives the packet and pushes it to its local client sockets.

2. The Slow Consumer Threat and Backpressure

In high-throughput systems, not all clients have fiber-optic internet. A user connected over an unstable 3G cellular network cannot drain packets as fast as your backend emits them.

If your backend code simply iterates over sockets using:

for socket in connections:
    await socket.send_text(data)

Two catastrophic failures occur:

  1. Head-of-Line Blocking: A slow or stalled client blocks the await loop, delaying delivery of time-critical messages to all subsequent healthy users.
  2. Buffer Bloat & OOM Kills: If you fire tasks asynchronously without bounded queues, the OS and application buffers accumulate un-acked packets in RAM until the Linux Out-Of-Memory (OOM) killer terminates the process.

The Solution: Isolate each connection with a dedicated Bounded Queue (asyncio.Queue(maxsize=100)). If a client’s queue fills up because it is failing to consume packets, the server immediately drops the connection and logs a slow-consumer eviction, preserving cluster stability for everyone else.

3. Half-Open Sockets and Zombie Connection Leakage

When a user closes their laptop lid, walks into an elevator, or experiences a cellular tower handoff, the TCP connection rarely terminates with a polite FIN/RST packet.

To the operating system, the socket appears open and healthy. Left unaddressed, hundreds of “ghost” connections accumulate daily, holding file descriptors and memory buffers indefinitely.

An enterprise WebSocket engine enforces an Active Protocol Heartbeat:

  • The server emits a lightweight binary ping frame every 30 seconds.
  • If the client fails to return a pong frame within 10 seconds, the server terminates the socket, cleans up the Redis subscription registry, and reclaims memory.

Code Comparison: Naive In-Memory vs Distributed Resilient Engine

Naive: Unbounded Single-Node In-Memory Broadcaster

# Naive WebSocket Manager: Single node, no backpressure, blocks on slow clients
from fastapi import FastAPI, WebSocket, WebSocketDisconnect

app = FastAPI()

class NaiveConnectionManager:
    def __init__(self):
        # FLAW 1: Confined to a single server node; cannot scale across replicas
        self.active_connections: list[WebSocket] = []

    async def connect(self, websocket: WebSocket):
        # FLAW 2: Zero authentication during handshake; accepts arbitrary sockets
        await websocket.accept()
        self.active_connections.append(websocket)

    def disconnect(self, websocket: WebSocket):
        self.active_connections.remove(websocket)

    async def broadcast(self, message: str):
        # FLAW 3: Iterates sequentially. If one client has high latency, 
        # the entire broadcast loop blocks for all other users!
        for connection in self.active_connections:
            await connection.send_text(message)

manager = NaiveConnectionManager()

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await manager.connect(websocket)
    try:
        while True:
            data = await websocket.receive_text()
            await manager.broadcast(data)
    except WebSocketDisconnect:
        manager.disconnect(websocket)

Production: Distributed Resilient WebSocket Engine with Backpressure

# Production Engine: Redis Pub/Sub, Ephemeral Auth Tickets, Bounded Queues & Heartbeats
import asyncio
import json
import logging
import time
from typing import Dict, Set
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, status
import redis.asyncio as aioredis
from pydantic import BaseModel, Field

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("scalable_ws")

app = FastAPI()
redis_client = aioredis.from_url("redis://localhost:6379", decode_responses=True)

class WebSocketMessage(BaseModel):
    event_type: str = Field(..., min_length=1, max_length=64)
    channel: str = Field(..., min_length=1, max_length=128)
    payload: dict

class ClientConnection:
    """Encapsulates a single client socket with a bounded outgoing backpressure queue."""
    def __init__(self, websocket: WebSocket, client_id: str, max_queue_size: int = 100):
        self.websocket = websocket
        self.client_id = client_id
        self.queue: asyncio.Queue[str] = asyncio.Queue(maxsize=max_queue_size)
        self.last_heartbeat = time.time()
        self.is_alive = True

    async def send_worker(self):
        """Dedicated background consumer draining the socket's bounded queue."""
        try:
            while self.is_alive:
                message = await self.queue.get()
                await self.websocket.send_text(message)
                self.queue.task_done()
        except Exception as exc:
            logger.warning(f"Error draining send queue for client {self.client_id}: {exc}")
            self.is_alive = False

class DistributedConnectionManager:
    def __init__(self):
        # Maps channel_name -> Set[ClientConnection]
        self.channel_subscribers: Dict[str, Set[ClientConnection]] = {}
        self.active_clients: Dict[str, ClientConnection] = {}
        self.pubsub = redis_client.pubsub()
        self.redis_listener_task: asyncio.Task = None

    async def start_redis_backplane(self):
        """Listens for cross-node broadcasts published on Redis."""
        await self.pubsub.psubscribe("channel:*")
        self.redis_listener_task = asyncio.create_task(self._listen_redis())

    async def _listen_redis(self):
        try:
            async for message in self.pubsub.listen():
                if message["type"] == "pmessage":
                    channel = message["channel"].replace("channel:", "")
                    data = message["data"]
                    await self._deliver_local(channel, data)
        except asyncio.CancelledError:
            pass

    async def _deliver_local(self, channel: str, message_str: str):
        """Pushes broadcast data into bounded client queues without head-of-line blocking."""
        subscribers = self.channel_subscribers.get(channel, set())
        for client in list(subscribers):
            try:
                # BACKPRESSURE SHIELD: Non-blocking put_nowait raises QueueFull on slow clients
                client.queue.put_nowait(message_str)
            except asyncio.QueueFull:
                logger.error(f"Slow consumer detected (client_id={client.client_id}). Dropping socket.")
                client.is_alive = False
                await client.websocket.close(code=status.WS_1008_POLICY_VIOLATION)
                self.unregister_client(client, channel)

    async def register_client(self, client: ClientConnection, channel: str):
        self.active_clients[client.client_id] = client
        if channel not in self.channel_subscribers:
            self.channel_subscribers[channel] = set()
        self.channel_subscribers[channel].add(client)
        # Start the client's individual consumer loop
        asyncio.create_task(client.send_worker())

    def unregister_client(self, client: ClientConnection, channel: str):
        client.is_alive = False
        if channel in self.channel_subscribers:
            self.channel_subscribers[channel].discard(client)
            if not self.channel_subscribers[channel]:
                del self.channel_subscribers[channel]
        self.active_clients.pop(client.client_id, None)

    async def publish_event(self, channel: str, payload: dict):
        """Publishes event to Redis backplane to propagate across all cluster nodes."""
        message = json.dumps(payload)
        await redis_client.publish(f"channel:{channel}", message)

manager = DistributedConnectionManager()

@app.on_event("startup")
async def startup_event():
    await manager.start_redis_backplane()

@app.websocket("/ws/v1/{channel}")
async def websocket_endpoint(
    websocket: WebSocket,
    channel: str,
    ticket: str = Query(...)
):
    # 1. Ephemeral Ticket Verification (<10ms)
    # Tickets expire in 30 seconds and are single-use to prevent URL token leakages
    client_id = await redis_client.get(f"auth:ws_ticket:{ticket}")
    if not client_id:
        await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
        return
    await redis_client.delete(f"auth:ws_ticket:{ticket}")

    await websocket.accept()
    client = ClientConnection(websocket, client_id=client_id)
    await manager.register_client(client, channel)

    # 2. Main Ingestion & Heartbeat Loop
    try:
        while client.is_alive:
            # Enforce 35-second heartbeat timeout (ping/pong)
            data_str = await asyncio.wait_for(websocket.receive_text(), timeout=35.0)
            data = json.loads(data_str)

            if data.get("type") == "pong":
                client.last_heartbeat = time.time()
                continue

            # Broadcast incoming message across all cluster nodes
            await manager.publish_event(channel, {
                "sender": client_id,
                "data": data,
                "timestamp": time.time()
            })
    except (asyncio.TimeoutError, WebSocketDisconnect):
        logger.info(f"Socket closed or heartbeat expired for client_id={client_id}")
    finally:
        manager.unregister_client(client, channel)

Real-World Case Studies: Concurrency Under Pressure

PróximaBolilla (proximabolilla.com): High-Concurrency Predictive Streams

PróximaBolilla provides real-time predictive probability computations and live lottery distribution updates across Latin America and Spain.

  • The Problem: During major national lottery draw broadcasts, user traffic surged simultaneously by 40x. Traditional HTTP short-polling overwhelmed server connection limits, while naive WebSocket prototypes crashed due to unhandled slow-client memory bloat.
  • The Solution: We deployed a distributed WebSocket cluster powered by Redis Pub/Sub and bounded client queues. The engine decoupled heavy stochastic Monte Carlo simulations from the real-time broadcast layer.
  • The Result: The platform handled over 1,000,000 lottery combinations evaluated and broadcast in real time with sub-50ms message latency, operating stably with zero dropped sockets and near-zero server idle overhead.

Strategio (Startup Perú 2G Winner): Live Analytics Dashboard

During the engineering of Strategio, an AI-powered Business Intelligence platform built from scratch in 12 weeks:

  • The Problem: Executive users demanded real-time sales anomalies and automated inventory alerts reflected instantly across their teams’ screens, but cloud infrastructure was strictly constrained to <$50 USD/month.
  • The Solution: Instead of spinning up expensive managed third-party socket services like Pusher or Ably, we engineered a native, lightweight WebSocket cluster with Redis Pub/Sub running inside lightweight Docker containers on Hetzner VPS instances.
  • The Result: Strategio served real-time analytics to corporate client dashboards with near-zero latency, slashing operational SaaS expenses by thousands of dollars annually.

Pre-Flight Checklist for High-Density WebSocket Engines

Before releasing a real-time WebSocket architecture to enterprise production, verify these five architectural gates:

  • Cross-Node Event Backplane: Is your socket layer decoupled using Redis Pub/Sub or Kafka so messages propagate across all load-balanced replicas?
  • Backpressure Protection: Does every socket maintain a bounded queue (asyncio.Queue(maxsize=N)) with automatic eviction of lagging clients to prevent OOM crashes?
  • Active Protocol Heartbeats: Does your server dispatch ping frames with strict timeouts to sweep away dead half-open connections?
  • Ephemeral Ticket Handshakes: Are connection tokens generated as single-use tickets (<30s TTL) rather than passing long-lived API keys directly in URL query parameters?
  • Client Thundering Herd Jitter: Does your frontend reconnect logic implement exponential backoff with randomized jitter to prevent server collapse following a network recovery?

Architectural Advisory

Designing robust, high-concurrency real-time architectures demands rigorous systems engineering, state isolation, and disciplined memory management.

At Coins5, Marlon helps venture-backed startups and growth-stage companies build horizontally scalable WebSocket platforms, distributed event pipelines, and real-time collaborative applications.

If your application is struggling with socket disconnects, high polling costs, or synchronization bugs across servers, schedule a 15-minute technical discovery call or connect via WhatsApp.

Building High-Density Real-Time Architectures?

Let's design a horizontally scalable WebSocket engine in a concise 15-minute technical discovery call.

Marlon - Senior Architect
Author & Systems Architect

Marlon / Coins5

Senior Full Stack Developer & Cloud Architect. Building custom software and production MVPs with Clean Architecture, high-concurrency cloud design, and AI velocity.

Book 15m Call