Volver al Blog
Tiempo Real WebSockets Backend FastAPI

WebSockets Escalables: Sistemas en Tiempo Real

• 11 min de lectura • Escrito por Marlon

TL;DR:

  • La Trampa: Gestores de WebSockets en memoria local (active_connections: list) carecen de distribución entre réplicas, fugan memoria por sockets medio abiertos y colapsan por excepciones Out-Of-Memory (OOM) cuando clientes lentos saturan el bucle de eventos.
  • La Arquitectura: Un pipeline distribuido de 4 etapas que integra apretones de manos con tickets criptográficos efímeros, colas locales acotadas con contrapresión (backpressure), un plano de eventos con Redis Pub/Sub y heartbeats activos mediante ping/pong binarios.
  • El Impacto: Soporta más de 100,000 conexiones concurrentes por nodo con latencias de difusión sub-50ms, reduciendo el consumo de CPU en más del 80% frente al sondeo HTTP tradicional (polling).

El Impuesto del Polling: Cómo las Peticiones Repetitivas Agotan tu Servidor

Simular tiempo real forzando al navegador o app móvil a consultar tu API REST cada dos o tres segundos (HTTP short-polling) es el error de diseño más costoso que puede cometer un equipo de desarrollo.

Cada solicitud HTTP estándar transmite cookies, cabeceras de autorización y negociación TLS innecesarias. Cuando 5,000 clientes activos consultan concurrentemente, tus servidores procesan 150,000 peticiones por minuto—de las cuales más del 98% devuelven respuestas vacías o códigos 304 porque ningún dato ha cambiado.

Esta saturación innecesaria agota los pools de conexiones a la base de datos, mantiene ocupados los workers del servidor web y multiplica exponencialmente las facturas de infraestructura en AWS o GCP.

Para soportar interactividad masiva en tiempo real—como paneles financieros, herramientas colaborativas, telemetría deportiva o dashboards operativos—las aplicaciones deben migrar a canales bidireccionales persistentes mediante WebSockets (RFC 6455).

Sin embargo, pasar de HTTP sin estado (stateless) a WebSockets con estado (stateful) introduce desafíos complejos de sistemas distribuidos: fugas de memoria en sockets zombi, avalanchas de reconexión (thundering herd) y sincronización de eventos entre múltiples servidores.

  1. ETAPA 01 <20ms

    Tickets Efímeros

    Canjea la sesión del usuario por un token criptográfico de un solo uso (<30s) verificado antes de actualizar la conexión.

  2. ETAPA 02 Nodo Local

    Pool Acotado

    Gestiona sockets en memoria con colas asyncio de tamaño fijo para aplicar contrapresión sobre clientes lentos.

  3. ETAPA 03 <2ms Pub/Sub

    Backplane con Redis

    Difunde eventos entre todas las réplicas del clúster, desacoplando la conexión física de la lógica de negocio.

  4. ETAPA 04 Limpieza

    Ping/Pong Adaptativo

    Envía latidos binarios cada 30 segundos; desconecta de forma forzosa sockets colgados en estado zombi.


Análisis Arquitectónico: Superando la Barrera del Nodo Único

1. El Dilema del Estado Distribuido

A diferencia de los endpoints REST donde cualquier réplica del servidor puede procesar cualquier solicitud entrante, los WebSockets mantienen estado: el cliente $A$ finaliza su socket TCP en la memoria RAM del Nodo 1, mientras que el cliente $B$ está conectado físicamente al Nodo 2.

Cuando se publica un evento para una sala o canal compartido, el Nodo 1 no puede escribir datos directamente en las conexiones TCP alojadas en el Nodo 2.

El patrón de producción estándar para resolver este desacoplamiento es un Plano de Control Distribuido con Redis Pub/Sub:

  • Cada nodo del servidor mantiene una conexión multiplexada persistente a una instancia central de Redis o clúster de Redis.
  • Los nodos se suscriben a patrones de canales correspondientes a sus salas activas locales (canal:{id}).
  • Cuando cualquier nodo recibe una mutación, publica el evento en Redis en menos de 2 milisegundos.
  • Todos los nodos con clientes activos reciben el paquete y lo transmiten inmediatamente a sus sockets locales.

2. El Problema del Consumidor Lento y la Contrapresión (Backpressure)

En entornos de alta concurrencia, no todos los clientes disponen de conexiones de fibra óptica. Un usuario en una red móvil inestable no puede drenar los paquetes al ritmo que el servidor los emite.

Si el backend transmite iterando secuencialmente sobre los sockets:

for socket in conexiones:
    await socket.send_text(datos)

Se producen dos fallos críticos:

  1. Bloqueo en Cabeza de Línea (Head-of-Line Blocking): Un cliente lento o congelado bloquea el bucle await, retrasando la entrega de mensajes críticos a todos los usuarios saludables posteriores.
  2. Saturación de Memoria y Colapso OOM: Si se lanzan tareas asíncronas sin límite, los buffers de la aplicación acumulan paquetes no confirmados en memoria RAM hasta que el sistema operativo mata el proceso (Out-Of-Memory Killer).

La Solución: Aislar cada conexión con una Cola Acotada (asyncio.Queue(maxsize=100)). Si la cola de un cliente se llena porque este no consume los datos a tiempo, el servidor expulsa inmediatamente la conexión y libera memoria, blindando la estabilidad de todo el clúster.

3. Sockets Medio Abiertos y Conexiones Zombie

Cuando un usuario cierra la tapa de su laptop, ingresa a un ascensor o cambia de antena móvil, la conexión TCP casi nunca finaliza con un paquete formal FIN/RST.

Para el sistema operativo, el socket parece abierto y activo. Sin un mecanismo de limpieza, cientos de conexiones “fantasma” se acumulan a diario, reteniendo descriptores de archivo y memoria RAM de forma indefinida.

Un motor de WebSockets empresarial implementa un Latido Activo de Protocolo (Heartbeat):

  • El servidor emite un frame binario ping cada 30 segundos.
  • Si el cliente no responde con un frame pong en un plazo máximo de 10 segundos, el servidor cierra el socket, limpia el registro en Redis y recupera los recursos de memoria.

Comparativa de Código: Gestor Mononodo Naive vs Motor Distribuido con Contrapresión

Naive: Gestor en Memoria Local sin Contrapresión ni Distribución

# Gestor Naive: Mononodo, sin autenticacion, bloquea ante clientes lentos
from fastapi import FastAPI, WebSocket, WebSocketDisconnect

app = FastAPI()

class NaiveConnectionManager:
    def __init__(self):
        # DEFECTO 1: Confinado a un solo nodo; imposible de escalar en cluster
        self.active_connections: list[WebSocket] = []

    async def connect(self, websocket: WebSocket):
        # DEFECTO 2: Cero autenticacion en el handshake; acepta cualquier socket
        await websocket.accept()
        self.active_connections.append(websocket)

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

    async def broadcast(self, message: str):
        # DEFECTO 3: Itera secuencialmente. Si un cliente tiene latencia alta,
        # todo el bucle se bloquea retrasando a los demas usuarios
        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)

Producción: Motor de WebSockets Distribuido con Redis y Contrapresión

# Motor de Produccion: Redis Pub/Sub, Tickets Efimeros, Colas Acotadas y 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:
    """Encapsula una conexion con cola acotada de contrapresion."""
    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):
        """Worker en segundo plano que vacia la cola acotada del socket."""
        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 al enviar mensaje a cliente {self.client_id}: {exc}")
            self.is_alive = False

class DistributedConnectionManager:
    def __init__(self):
        # Mapea nombre_canal -> 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):
        """Escucha eventos difundidos entre nodos en 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):
        """Pone los mensajes en las colas acotadas locales sin bloqueo secuencial."""
        subscribers = self.channel_subscribers.get(channel, set())
        for client in list(subscribers):
            try:
                # ESCUDO DE CONTRAPRESION: put_nowait lanza QueueFull si el cliente es lento
                client.queue.put_nowait(message_str)
            except asyncio.QueueFull:
                logger.error(f"Consumidor lento detectado ({client.client_id}). Desconectando 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)
        # Iniciar bucle consumidor individual del cliente
        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):
        """Publica el evento en Redis para que todas las replicas lo reciban."""
        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. Verificacion de Ticket Efimero (<10ms)
    # Tickets de un solo uso con vigencia de 30 segundos
    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. Bucle Principal de Ingesta y Heartbeats
    try:
        while client.is_alive:
            # Limite estricto de timeout de 35 segundos para latidos 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

            # Difundir mensaje recibido a traves de todo el cluster de servidores
            await manager.publish_event(channel, {
                "sender": client_id,
                "data": data,
                "timestamp": time.time()
            })
    except (asyncio.TimeoutError, WebSocketDisconnect):
        logger.info(f"Socket cerrado o latido expirado para client_id={client_id}")
    finally:
        manager.unregister_client(client, channel)

Casos de Éxito Reales: Concurrencia Bajo Presión

PróximaBolilla (proximabolilla.com): Transmisión Predictiva Concurrente

PróximaBolilla ofrece cálculos predictivos de distribución probabilística y actualizaciones en vivo de loterías en América Latina y España.

  • El Problema: Durante las transmisiones en vivo de sorteos oficiales, el tráfico de usuarios se multiplicaba por 40 en cuestión de segundos. El sondeo HTTP colapsaba los servidores, y los prototipos iniciales de WebSockets sufrían caídas por acumulación de memoria en clientes lentos.
  • El Solución: Implementamos un clúster de WebSockets distribuido con Redis Pub/Sub y colas acotadas por cliente, desacoplando las simulaciones de Monte Carlo del flujo de difusión en tiempo real.
  • El Resultado: La plataforma transmitió y evaluó más de 1,000,000 de combinaciones numéricas en tiempo real con latencias menores a 50ms, operando con cero pérdidas de sockets y costos mínimos de servidor.

Strategio (Ganador Startup Perú 2da Generación): Dashboards Analíticos en Vivo

Durante el desarrollo de Strategio, la plataforma de Inteligencia de Negocios creada desde cero en 12 semanas:

  • El Problema: Los directivos requerían ver anomalías en ventas y alertas de stock de manera instantánea en sus pantallas, pero el presupuesto en la nube estaba limitado estrictamente a menos de $50 USD/mes.
  • La Solución: En lugar de contratar servicios de sockets de terceros de alto costo como Pusher o Ably, diseñamos un clúster ligero nativo de WebSockets con Redis Pub/Sub sobre contenedores Docker en VPS de Hetzner.
  • El Resultado: Strategio entregó analítica en tiempo real a clientes corporativos con latencia casi nula, reduciendo miles de dólares en costos operativos anuales de software.

Lista de Verificación Previa para Motores de WebSockets de Alta Densidad

Antes de lanzar una arquitectura de WebSockets a producción corporativa, valida estos cinco puntos de control:

  • Plano de Eventos Distribuido: ¿Tu capa de sockets está desacoplada mediante Redis Pub/Sub para que los eventos lleguen a todas las réplicas detrás del balanceador?
  • Protección por Contrapresión (Backpressure): ¿Cada socket dispone de una cola acotada (asyncio.Queue(maxsize=N)) que expulsa a clientes lentos antes de provocar un fallo por memoria (OOM)?
  • Heartbeats Activos a Nivel de Protocolo: ¿El servidor emite tramas ping con timeouts definidos para eliminar conexiones medio abiertas?
  • Handshakes con Tickets Efímeros: ¿Los tokens de conexión son de un solo uso (<30s) en lugar de exponer claves de API permanentes en las URLs?
  • Jitter contra Avalanchas de Reconexión: ¿El frontend implementa backoff exponencial con variación aleatoria (full jitter) para no colapsar el gateway al reconectar tras caídas de red?

Asesoría de Arquitectura

Diseñar infraestructuras en tiempo real tolerantes a fallos exige rigor técnico en sistemas distribuidos, aislamiento de memoria y control de concurrencia.

En Coins5, Marlon acompaña a startups y empresas en crecimiento en el diseño de plataformas escalables de WebSockets, buses de eventos distribuidos y aplicaciones colaborativas de alto rendimiento.

Si tu producto actual experimenta desconexiones recurrentes, costos elevados por sondeo HTTP o desincronización entre servidores, agendemos una llamada de descubrimiento técnico de 15 minutos.

¿Construyendo Arquitecturas en Tiempo Real de Alta Densidad?

Diseñemos un motor de WebSockets escalable horizontalmente en una llamada técnica de 15 minutos.

Marlon - Senior Architect
Autor & Arquitecto de Software

Marlon / Coins5

Senior Full Stack Developer & Arquitecto Cloud. Desarrollo de software a medida y MVPs robustos combinando Clean Architecture, diseño de sistemas de alta concurrencia y velocidad asistida por IA.

Agendar Llamada