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.

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ón | HTTP funciona | WebSockets es mejor |
|---|---|---|
| Dashboard que se refresca cada 30s | ✅ El polling está bien | Excesivo |
| Mensajes de chat en vivo | El polling desperdicia ancho de banda | ✅ Entrega instantánea |
| Edición colaborativa de documentos | No 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
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.
// ❌ 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.
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.
// ✅ 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.
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
- Usa WebSockets para comunicación bidireccional en tiempo real — SSE es más simple para patrones donde solo el servidor empuja
- Autentica durante el handshake — rechaza las conexiones sin autenticar de inmediato
- Define un protocolo de mensajes tipado — usa namespaces con dos puntos para organizarlos
- Implementa reconexión con backoff exponencial en el cliente para evitar thundering herds
- Los pings de heartbeat detectan conexiones muertas — sin ellos, las conexiones obsoletas filtran memoria


