Saltar al contenido

Fundamentos de WebSocket para desarrolladores backend

Cuando el polling HTTP no basta: guía práctica de conexiones WebSocket, patrones de mensajes y aspectos de producción como reconexión y escalado.

3 min de lectura
Diagrama del ciclo de vida de una conexión WebSocket que muestra el handshake, el intercambio de mensajes y el cierre

HTTP es solicitud-respuesta. El cliente pregunta, el servidor responde. Para funcionalidades en tiempo real — notificaciones en vivo, edición colaborativa, chat, streaming de datos — este modelo obliga a soluciones incómodas como el long polling. Los WebSockets resuelven esto con una conexión persistente y bidireccional donde cualquiera de los dos lados puede enviar datos en cualquier momento.

Cuándo tienen sentido los WebSockets

No toda funcionalidad en tiempo real necesita WebSockets. Úsalos cuando el servidor necesite enviar datos a los clientes sin que se lo pidan.

PatrónHTTP funcionaWebSockets es mejor
Dashboard que se refresca cada 30s✅ El polling está bienExcesivo
Mensajes de chat en vivoEl polling desperdicia ancho de banda✅ Entrega instantánea
Edición colaborativa de documentosNo es viable✅ Requerido
Ticker de precios de acciones✅ SSE es más simple✅ Si se necesita bidireccional
Campanita de notificaciones con contador✅ Polling o SSE✅ Si ya usas WS

Los Server-Sent Events (SSE) manejan el patrón "el servidor empuja, el cliente escucha" de forma más simple que los WebSockets. Recurre a WebSockets solo cuando necesites comunicación bidireccional.

Configuración básica del servidor

tstypescript
import { WebSocketServer, WebSocket } from "ws";
import { createServer } from "http";
 
const server = createServer();
const wss = new WebSocketServer({ server });
 
wss.on("connection", (ws: WebSocket, req) => {
  const userId = authenticateFromRequest(req);
  if (!userId) {
    ws.close(4001, "Unauthorized");
    return;
  }
 
  console.log(`Client connected: ${userId}`);
 
  ws.on("message", (data) => {
    const message = JSON.parse(data.toString());
    handleMessage(ws, userId, message);
  });
 
  ws.on("close", (code, reason) => {
    console.log(`Client disconnected: ${userId}, code: ${code}`);
    cleanupConnection(userId);
  });
 
  ws.on("error", (error) => {
    console.error(`WebSocket error for ${userId}:`, error.message);
  });
 
  // Send initial state
  ws.send(JSON.stringify({ type: "connected", userId }));
});
 
server.listen(3001);

Autentica siempre durante el handshake de conexión, no después. Una conexión WebSocket sin autenticar es una puerta abierta.

Diseño del protocolo de mensajes

Define un protocolo de mensajes tipado desde el principio. Sin eso, terminas parseando blobs sin estructura y esperando lo mejor.

tstypescript
// ❌ Untyped messages — no contract, no validation
ws.send("hello");
ws.send(JSON.stringify({ action: "send", text: "hi" }));
ws.send(JSON.stringify({ type: "msg", body: "hello" }));
 
// ✅ Typed message protocol — both sides know the contract
type ClientMessage =
  | { type: "chat:send"; roomId: string; content: string }
  | { type: "chat:typing"; roomId: string }
  | { type: "presence:update"; status: "online" | "away" };
 
type ServerMessage =
  | { type: "chat:new"; roomId: string; message: ChatMessage }
  | { type: "chat:typing"; roomId: string; userId: string }
  | { type: "presence:changed"; userId: string; status: string }
  | { type: "error"; code: string; message: string };
 
function handleMessage(ws: WebSocket, userId: string, msg: ClientMessage) {
  switch (msg.type) {
    case "chat:send":
      broadcastToRoom(msg.roomId, {
        type: "chat:new",
        roomId: msg.roomId,
        message: { userId, content: msg.content, timestamp: Date.now() },
      });
      break;
    case "chat:typing":
      broadcastToRoom(msg.roomId, {
        type: "chat:typing",
        roomId: msg.roomId,
        userId,
      });
      break;
  }
}

Usa namespaces con dos puntos para los tipos de mensaje (chat:send, presence:update). Mantiene el protocolo organizado a medida que crece.

Gestión de conexiones

Registra los clientes conectados en un mapa para mensajería dirigida y limpieza.

tstypescript
const connections = new Map<string, WebSocket>();
 
function registerConnection(userId: string, ws: WebSocket) {
  // Close existing connection if user reconnects
  const existing = connections.get(userId);
  if (existing?.readyState === WebSocket.OPEN) {
    existing.close(4000, "Replaced by new connection");
  }
 
  connections.set(userId, ws);
}
 
function sendToUser(userId: string, message: ServerMessage) {
  const ws = connections.get(userId);
  if (ws?.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify(message));
  }
}
 
function broadcastToRoom(roomId: string, message: ServerMessage) {
  const members = getRoomMembers(roomId);
  const payload = JSON.stringify(message);
 
  for (const userId of members) {
    const ws = connections.get(userId);
    if (ws?.readyState === WebSocket.OPEN) {
      ws.send(payload);
    }
  }
}

Reconexión del lado del cliente

Las conexiones se caen. Las redes cambian. Los servidores se reinician. El cliente debe manejar la reconexión con elegancia.

tstypescript
// ✅ Reconnecting WebSocket client with exponential backoff
function createReliableSocket(url: string) {
  let ws: WebSocket | null = null;
  let retryCount = 0;
  const maxRetryDelay = 30000;
 
  function connect() {
    ws = new WebSocket(url);
 
    ws.onopen = () => {
      retryCount = 0; // Reset backoff on successful connection
      console.log("WebSocket connected");
    };
 
    ws.onclose = (event) => {
      if (event.code === 4001) return; // Auth failure — don't retry
 
      const delay = Math.min(1000 * 2 ** retryCount, maxRetryDelay);
      retryCount++;
      console.log(`Reconnecting in ${delay}ms...`);
      setTimeout(connect, delay);
    };
 
    ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      handleServerMessage(message);
    };
  }
 
  connect();
 
  return {
    send: (msg: ClientMessage) => {
      if (ws?.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify(msg));
      }
    },
  };
}

El backoff exponencial previene thundering herds cuando el servidor se reinicia y todos los clientes intentan reconectarse simultáneamente.

Heartbeats y conexiones muertas

Las conexiones TCP pueden morir silenciosamente (cambio de red, laptop en suspensión). Los heartbeats detectan conexiones muertas para que el servidor pueda liberar recursos.

tstypescript
const HEARTBEAT_INTERVAL = 30000;
const HEARTBEAT_TIMEOUT = 10000;
 
wss.on("connection", (ws) => {
  let isAlive = true;
 
  ws.on("pong", () => {
    isAlive = true;
  });
 
  const heartbeat = setInterval(() => {
    if (!isAlive) {
      ws.terminate();
      clearInterval(heartbeat);
      return;
    }
 
    isAlive = false;
    ws.ping();
  }, HEARTBEAT_INTERVAL);
 
  ws.on("close", () => clearInterval(heartbeat));
});

Sin heartbeats, las conexiones muertas se acumulan y desperdician memoria del servidor. Una conexión que muere silenciosamente nunca dispara el evento close.

Puntos clave

  1. Usa WebSockets para comunicación bidireccional en tiempo real — SSE es más simple para patrones donde solo el servidor empuja
  2. Autentica durante el handshake — rechaza las conexiones sin autenticar de inmediato
  3. Define un protocolo de mensajes tipado — usa namespaces con dos puntos para organizarlos
  4. Implementa reconexión con backoff exponencial en el cliente para evitar thundering herds
  5. Los pings de heartbeat detectan conexiones muertas — sin ellos, las conexiones obsoletas filtran memoria
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX