Tutorial

Construyendo Aplicaciones en Tiempo Real con WebSockets

Resumen

WebSockets habilitan comunicación persistente y bidireccional para características en tiempo real. Maneja el ciclo de vida de conexión cuidadosamente, implementa heartbeats para detectar conexiones muertas, usa Redis pub/sub para escalado horizontal y siempre ten mecanismos de fallback para confiabilidad.

2 de enero, 20265 min de lectura
WebSocketsTiempo RealNode.jsPythonTutorialDesarrollo Web

Las características en tiempo real—chat en vivo, notificaciones, edición colaborativa, juegos—requieren conexiones persistentes entre cliente y servidor. WebSockets proveen exactamente eso. Este tutorial recorre la construcción de aplicaciones en tiempo real listas para producción.

Entendiendo WebSockets

┌─────────────────────────────────────────────────────────────────┐
│               Comunicación HTTP vs WebSocket                     │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   HTTP (Solicitud-Respuesta):                                   │
│                                                                  │
│   Cliente ──────► Servidor    (Solicitud)                       │
│   Cliente ◄────── Servidor    (Respuesta)                       │
│   [Conexión cerrada]                                            │
│                                                                  │
│   ─────────────────────────────────────────────────────────────  │
│                                                                  │
│   WebSocket (Persistente, Bidireccional):                       │
│                                                                  │
│   Cliente ══════► Servidor    (HTTP Upgrade handshake)          │
│   Cliente ◄══════► Servidor   [Conexión permanece abierta]      │
│                                                                  │
│   Cliente ──────► Servidor    (Mensaje en cualquier momento)    │
│   Cliente ◄────── Servidor    (Mensaje en cualquier momento)    │
│   Cliente ◄────── Servidor    (Push sin solicitud)              │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Implementación Básica del Servidor

Node.js con Librería ws

// server.ts
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';
import { v4 as uuid } from 'uuid';
 
interface Client {
  id: string;
  socket: WebSocket;
  userId?: string;
  rooms: Set<string>;
  lastPing: number;
}
 
class WebSocketManager {
  private wss: WebSocketServer;
  private clients: Map<string, Client> = new Map();
  private rooms: Map<string, Set<string>> = new Map();
 
  constructor(server: ReturnType<typeof createServer>) {
    this.wss = new WebSocketServer({ server });
    this.setupConnectionHandler();
    this.startHeartbeat();
  }
 
  private setupConnectionHandler() {
    this.wss.on('connection', (socket, request) => {
      const clientId = uuid();
      const client: Client = {
        id: clientId,
        socket,
        rooms: new Set(),
        lastPing: Date.now(),
      };
 
      this.clients.set(clientId, client);
      console.log(`Cliente conectado: ${clientId}`);
 
      // Enviar reconocimiento de conexión
      this.send(socket, {
        type: 'connected',
        clientId,
      });
 
      // Manejar mensajes entrantes
      socket.on('message', (data) => {
        try {
          const message = JSON.parse(data.toString());
          this.handleMessage(client, message);
        } catch (error) {
          console.error('Formato de mensaje inválido:', error);
        }
      });
 
      // Manejar desconexión
      socket.on('close', () => {
        this.handleDisconnect(client);
      });
 
      // Responder a pings
      socket.on('pong', () => {
        client.lastPing = Date.now();
      });
    });
  }
 
  private startHeartbeat() {
    setInterval(() => {
      const now = Date.now();
      const timeout = 30000; // 30 segundos
 
      for (const [clientId, client] of this.clients) {
        if (now - client.lastPing > timeout) {
          // La conexión está muerta
          console.log(`Cliente ${clientId} expiró`);
          client.socket.terminate();
          this.handleDisconnect(client);
        } else if (client.socket.readyState === WebSocket.OPEN) {
          // Enviar ping
          client.socket.ping();
        }
      }
    }, 10000); // Verificar cada 10 segundos
  }
}

Insight Clave

Siempre implementa heartbeat/ping-pong para detectar conexiones muertas. TCP no te notifica cuando una conexión se cae silenciosamente (ej. falla de red del cliente). Sin heartbeats, tendrás conexiones zombie consumiendo recursos.

Implementación del Cliente

React Hook para WebSocket

// useWebSocket.ts
import { useEffect, useRef, useCallback, useState } from 'react';
 
interface UseWebSocketOptions {
  url: string;
  onMessage?: (message: any) => void;
  onOpen?: () => void;
  reconnect?: boolean;
  reconnectAttempts?: number;
  reconnectInterval?: number;
}
 
export function useWebSocket({
  url,
  onMessage,
  onOpen,
  reconnect = true,
  reconnectAttempts = 5,
  reconnectInterval = 3000,
}: UseWebSocketOptions) {
  const [isConnected, setIsConnected] = useState(false);
  const wsRef = useRef<WebSocket | null>(null);
  const reconnectCountRef = useRef(0);
 
  const connect = useCallback(() => {
    const ws = new WebSocket(url);
 
    ws.onopen = () => {
      setIsConnected(true);
      reconnectCountRef.current = 0;
      onOpen?.();
 
      // Iniciar heartbeat del lado del cliente
      const heartbeat = setInterval(() => {
        if (ws.readyState === WebSocket.OPEN) {
          ws.send(JSON.stringify({ type: 'ping' }));
        }
      }, 25000);
 
      ws.addEventListener('close', () => clearInterval(heartbeat));
    };
 
    ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      onMessage?.(message);
    };
 
    ws.onclose = () => {
      setIsConnected(false);
 
      // Intentar reconexión
      if (reconnect && reconnectCountRef.current < reconnectAttempts) {
        reconnectCountRef.current++;
        const delay = reconnectInterval * Math.pow(2, reconnectCountRef.current - 1);
        console.log(`Reconectando en ${delay}ms (intento ${reconnectCountRef.current})`);
        setTimeout(connect, delay);
      }
    };
 
    wsRef.current = ws;
  }, [url, onMessage, onOpen, reconnect, reconnectAttempts, reconnectInterval]);
 
  const send = useCallback((message: any) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify(message));
    }
  }, []);
 
  useEffect(() => {
    connect();
    return () => wsRef.current?.close();
  }, [connect]);
 
  return { send, isConnected };
}

Escalando con Redis Pub/Sub

// Escalando WebSockets a través de múltiples servidores
import Redis from 'ioredis';
 
class ScaledWebSocketManager extends WebSocketManager {
  private publisher: Redis;
  private subscriber: Redis;
 
  constructor(server: ReturnType<typeof createServer>) {
    super(server);
 
    this.publisher = new Redis(process.env.REDIS_URL);
    this.subscriber = new Redis(process.env.REDIS_URL);
 
    this.setupPubSub();
  }
 
  private setupPubSub() {
    // Suscribirse a todos los canales de sala
    this.subscriber.psubscribe('room:*');
 
    this.subscriber.on('pmessage', (pattern, channel, message) => {
      const roomId = channel.replace('room:', '');
      const parsed = JSON.parse(message);
 
      // Solo transmitir si el mensaje no se originó en este servidor
      if (parsed.serverId !== this.serverId) {
        this.localBroadcastToRoom(roomId, parsed.message);
      }
    });
  }
 
  // Sobrescribir para publicar a Redis en lugar de broadcast solo local
  protected broadcastToRoom(roomId: string, message: any, excludeClientId?: string) {
    // Publicar a Redis para otros servidores
    this.publisher.publish(`room:${roomId}`, JSON.stringify({
      serverId: this.serverId,
      message,
      excludeClientId,
    }));
 
    // También transmitir localmente
    this.localBroadcastToRoom(roomId, message, excludeClientId);
  }
}

Consideraciones de Seguridad

Autenticación Durante el Handshake

// Servidor WebSocket seguro con autenticación JWT
const wss = new WebSocketServer({
  server,
  verifyClient: async (info, callback) => {
    try {
      const url = new URL(info.req.url!, `http://${info.req.headers.host}`);
      const token = url.searchParams.get('token');
 
      if (!token) {
        callback(false, 401, 'No autorizado');
        return;
      }
 
      const user = await verifyJWT(token);
      (info.req as any).user = user;
      callback(true);
    } catch (error) {
      callback(false, 401, 'Token inválido');
    }
  },
});

Conclusión

Construir aplicaciones WebSocket de producción requiere atención a:

  1. Ciclo de vida de conexión - Maneja connect, disconnect y errores graciosamente
  2. Heartbeats - Detecta conexiones muertas con ping/pong
  3. Escalado - Usa pub/sub para despliegues multi-servidor
  4. Seguridad - Autentica durante handshake, valida todos los mensajes
  5. Confiabilidad - Implementa reconexión con backoff exponencial
  6. Rate limiting - Protege contra abuso

WebSockets desbloquean experiencias en tiempo real poderosas. Úsalos cuando el beneficio de UX justifique la complejidad operacional.


Referencias

Fette, I., & Melnikov, A. (2011). The WebSocket Protocol (RFC 6455). IETF. https://tools.ietf.org/html/rfc6455

MDN Web Docs. (2024). WebSocket API. https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API

Socket.IO. (2024). Socket.IO documentation. https://socket.io/docs/v4/


¿Construyendo características en tiempo real? Contáctame para discutir estrategias de arquitectura WebSocket.

Frequently Asked Questions

OR

Osvaldo Restrepo

Senior Full Stack AI & Software Engineer. Building production AI systems that solve real problems.