Crea un servidor WebSocket (Python, Node.js, Java)
Cómo construir un servidor WebSocket para comunicación en tiempo real bidireccional, con gestión de conexiones, difusión de mensajes y heartbeats.
Visión General
El polling HTTP no puede entregar un mensaje de chat en el momento en que ocurre: o quemas peticiones preguntando “¿hay algo nuevo?” o aceptas segundos de latencia. WebSockets lo resuelve con una única conexión TCP que permanece abierta para que ambas partes envíen tramas cuando lo necesiten, y eso es exactamente lo que construye esta receta: servidores WebSocket en Python, Node.js y Java (Spring Boot), con gestión de conexiones, heartbeats, difusión por salas y un cliente de navegador que se reconecta solo.
Cuándo Usar
Usa este recurso cuando:
- Necesitas comunicación en tiempo real verdaderamente bidireccional (chat, edición colaborativa, juegos multijugador)
- Tu aplicación requiere actualizaciones de baja latencia en ambas direcciones (cliente → servidor y servidor → cliente)
- Quieres evitar la sobrecarga del polling HTTP o la limitación unidireccional de SSE
- Necesitas enviar datos a clientes o grupos específicos (salas/canales) según la lógica de negocio
No uses WebSockets cuando solo necesitas push de servidor a cliente: Server-Sent Events te da eso sobre HTTP plano, con reconexión automática y sin registro de conexiones que mantener. Y si el request/response cubre el caso, un WebSocket solo añade un ciclo de vida de conexión que vigilar.
Solución
Python (librería websockets)
import asyncio
import json
import websockets
connected = set()
rooms = {} # nombre_sala -> conjunto de websockets
async def register(websocket):
connected.add(websocket)
async def unregister(websocket):
connected.discard(websocket)
# Quitar de todas las salas
for room in rooms.values():
room.discard(websocket)
async def broadcast_to_room(room, message):
if room in rooms:
# Copiar el set: los clientes pueden desconectarse durante la iteración
for client in rooms[room].copy():
try:
await client.send(message)
except websockets.ConnectionClosed:
pass
async def handler(websocket):
await register(websocket)
try:
async for message in websocket:
try:
data = json.loads(message)
except json.JSONDecodeError:
await websocket.send(json.dumps({"error": "JSON inválido"}))
continue
action = data.get("action")
room = data.get("room")
if action == "join" and room:
rooms.setdefault(room, set()).add(websocket)
await websocket.send(json.dumps({"status": "joined", "room": room}))
elif action == "message" and room:
await broadcast_to_room(room, json.dumps({
"room": room,
"user": data.get("user", "anonymous"),
"message": data.get("message", "")
}))
elif action == "broadcast":
for client in connected.copy():
if client != websocket:
try:
await client.send(json.dumps({"message": data.get("message", "")}))
except websockets.ConnectionClosed:
pass
finally:
await unregister(websocket)
async def main():
# ping_interval/ping_timeout detectan conexiones muertas que proxies,
# gateways NAT o balanceadores de carga cierran en silencio
async with websockets.serve(
handler, "localhost", 8765,
ping_interval=30, ping_timeout=10
):
await asyncio.Future() # ejecutar indefinidamente
if __name__ == "__main__":
asyncio.run(main())
Requiere websockets 10.1+, donde el handler recibe un único argumento websocket (se eliminó el antiguo parámetro path) y el keepalive a nivel de protocolo viene integrado con ping_interval/ping_timeout, sin bucle de heartbeat manual.
JavaScript (Node.js con ws)
const WebSocket = require("ws");
const wss = new WebSocket.Server({ port: 8765 });
const clients = new Map(); // websocket -> metadatos
const rooms = new Map(); // sala -> Set de websockets
wss.on("connection", (ws, req) => {
const clientId = generateId();
clients.set(ws, { id: clientId, rooms: new Set() });
ws.isAlive = true;
ws.on("pong", () => { ws.isAlive = true; });
console.log(`Cliente ${clientId} conectado`);
ws.send(JSON.stringify({ type: "welcome", id: clientId }));
ws.on("message", (rawMessage) => {
let data;
try {
data = JSON.parse(rawMessage);
} catch {
ws.send(JSON.stringify({ type: "error", message: "JSON inválido" }));
return;
}
if (data.action === "join") {
const room = data.room;
clients.get(ws).rooms.add(room);
if (!rooms.has(room)) rooms.set(room, new Set());
rooms.get(room).add(ws);
ws.send(JSON.stringify({ type: "joined", room }));
} else if (data.action === "message") {
broadcastToRoom(data.room, JSON.stringify({
type: "message",
room: data.room,
user: data.user || "anonymous",
message: data.message
}));
} else if (data.action === "broadcast") {
const message = JSON.stringify({ type: "broadcast", message: data.message });
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) client.send(message);
});
}
});
ws.on("close", () => {
const meta = clients.get(ws);
if (meta) {
meta.rooms.forEach(room => rooms.get(room)?.delete(ws));
clients.delete(ws);
console.log(`Cliente ${meta.id} desconectado`);
}
});
ws.on("error", (err) => {
console.error("Error de WebSocket:", err);
});
});
function broadcastToRoom(room, message) {
const members = rooms.get(room);
if (!members) return;
members.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) ws.send(message);
});
}
function generateId() {
return Math.random().toString(36).substring(2, 9);
}
// Heartbeat: ping cada 30s, termina los clientes que no respondieron pong
const heartbeat = setInterval(() => {
wss.clients.forEach(ws => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on("close", () => clearInterval(heartbeat));
A diferencia de un bucle ws.ping() sin más, esto lleva un isAlive por conexión: cualquier cliente que no responda pong antes del siguiente intervalo se termina, así las conexiones muertas no se acumulan en silencio.
Java (Spring Boot con STOMP)
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.*;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
// "*" acepta upgrades desde cualquier origen — solo desarrollo.
// En producción, lista tus dominios reales y rechaza el resto
// para evitar cross-site WebSocket hijacking (CSWSH).
.setAllowedOriginPatterns("*")
.withSockJS();
}
}
// Controller
import org.springframework.messaging.handler.annotation.*;
import org.springframework.stereotype.Controller;
@Controller
public class ChatController {
@MessageMapping("/chat/{room}")
@SendTo("/topic/rooms/{room}")
public ChatMessage send(@DestinationVariable String room,
@Payload ChatMessage message) {
return message;
}
@MessageMapping("/broadcast")
@SendTo("/topic/broadcast")
public String broadcast(@Payload String message) {
return message;
}
}
// Clase de mensaje
public record ChatMessage(String user, String message, long timestamp) {}
Cliente del Navegador (con reconexión)
class ReconnectingWebSocket {
constructor(url) {
this.url = url;
this.ws = null;
this.reconnectDelay = 1000;
this.maxDelay = 30000;
this.onMessage = () => {};
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.reconnectDelay = 1000; // reiniciar el backoff
// Reunirse a las salas aquí: la reconexión pierde el estado del servidor
this.send({ action: "join", room: "general" });
};
this.ws.onmessage = (event) => {
this.onMessage(JSON.parse(event.data));
};
this.ws.onclose = () => {
setTimeout(() => this.connect(), this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxDelay);
};
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
}
const socket = new ReconnectingWebSocket("ws://localhost:8765");
socket.onMessage = (data) => {
if (data.type === "message") appendMessage(data.room, data.user, data.message);
if (data.type === "broadcast") showNotification(data.message);
};
function sendMessage(room, text) {
socket.send({ action: "message", room, user: "alice", message: text });
}
Los navegadores no reconectan WebSockets automáticamente. El backoff exponencial de arriba (1s → 2s → 4s … con tope de 30s) es la versión mínima viable; los clientes en producción suelen añadir jitter aleatorio para que cientos de usuarios no golpeen el servidor al mismo instante tras una caída.
Para probar cualquiera de los servidores anteriores sin escribir un cliente, instala wscat (npm i -g wscat) y ejecuta wscat -c ws://localhost:8765, o pega la línea new WebSocket(...) directamente en la consola de DevTools del navegador.
Explicación
- Handshake de protocolo — WebSockets comienzan como una petición HTTP con las cabeceras
Upgrade: websocketyConnection: Upgrade. El servidor devuelve una respuesta101 Switching Protocols, tras la cual la conexión TCP pasa al protocolo de tramas binarias de WebSocket. - Gestión de conexiones — mantén un registro de conexiones activas (sets o maps). Cuando un cliente se desconecta, quítalo del registro para evitar fugas de memoria y envíos fantasma. Itera sobre una copia del set al difundir, porque enviar a un socket moribundo muta el registro en pleno bucle.
- Heartbeats — proxies, gateways NAT y balanceadores de carga cierran conexiones WebSocket en silencio, y ninguna de las partes se entera hasta que un envío falla. El
ping/ponga nivel de protocolo cada ~30 segundos es lo que detecta de verdad las muertas, pero solo si también cierras las que no responden, como en el patrónisAliveanterior. - Arquitectura de salas/canales — asocia nombres de sala a conjuntos de conexiones y reparte solo a los miembros. Difundir todo a todos funciona con diez clientes y se cae con diez mil.
- Usa
wss://en producción —ws://envía el tráfico sin cifrar y los navegadores lo bloquean como contenido mixto en páginas HTTPS.wss://viaja sobre TLS, igual quehttps://.
Variantes
| Framework | Capacidades de protocolo | Ideal para |
|---|---|---|
Python websockets | WebSocket puro, asyncio | Microservicios, protocolos propios |
Node.js ws | WebSocket puro, alto rendimiento | Juegos en tiempo real, chat a escala |
| Spring Boot STOMP | Subprotocolo sobre WebSocket | Aplicaciones empresariales, mensajería pub/sub |
| Socket.IO | WebSocket + respaldo HTTP | Aplicaciones de navegador que necesitan fallback |
| AWS API Gateway | WebSocket gestionado | Arquitecturas serverless |
Si los clientes están detrás de proxies corporativos que bloquean por completo el upgrade de WebSocket, degrada a SSE para flujos de servidor a cliente o a long-polling HTTP cuando de verdad necesites bidireccionalidad — ese es el camino de respaldo que Socket.IO automatiza.
Construyendo una aplicación de chat
Un chat sobre este servidor añade cuatro cosas que la receta base no cubre: presencia, indicadores de escritura, persistencia de mensajes y fan-out entre instancias. El proyecto companion lo trae todo como app Node.js ejecutable (servidor, relay de Redis, manejador de escritura y un hook useChat de React con reconexión por backoff); las piezas esenciales van abajo.
Presencia e indicadores de escritura
Guarda un conjunto de usuarios escribiendo por sala y difunde la lista de miembros en cada alta y baja. Los eventos de escritura viajan por el mismo camino que la presencia pero son efímeros: nunca se persisten.
// server/typing.ts
const typingUsers = new Map<string, Set<string>>();
export function handleTyping(client: ChatClient, isTyping: boolean) {
if (!typingUsers.has(client.roomId)) {
typingUsers.set(client.roomId, new Set());
}
const room = typingUsers.get(client.roomId)!;
if (isTyping) room.add(client.userId);
else room.delete(client.userId);
broadcastToRoom(client.roomId, {
type: 'typing',
users: Array.from(room),
});
}
Fan-out multi-servidor con Redis pub/sub
El mapa de salas solo ve los sockets de su propio proceso. Publica cada mensaje de chat en un canal compartido y deja que cada instancia — incluida la que lo aceptó — lo reenvíe a sus salas locales. Esa ruta única es cómo un cliente del servidor B ve lo que envió uno del servidor A. Difundir en local además del relay es la forma clásica de entregar cada mensaje dos veces.
// server/redis-broadcast.ts
const subscriber = redis.duplicate();
await subscriber.connect();
// node-redis v4: subscribe(channel, (message, channel) => void)
await subscriber.subscribe('chat:messages', (raw) => {
const { roomId, message } = JSON.parse(raw);
broadcastToRoom(roomId, message);
});
Persistencia y recuperación tras reconectar
Persiste cada mensaje de chat antes de publicarlo, y sirve el historial con paginación por cursor para que el cliente que reconecta recupere lo que se perdió. Asigna un número de secuencia a cada mensaje en el servidor en lugar de fiarte de los timestamps del cliente; tras reconectar, el cliente pide todo desde su última seq vista y lo integra en la línea temporal.
Lo que funciona
- Siempre maneja los errores de conexión — los fallos de red, las caídas de clientes y los timeouts de proxies dejan conexiones residuales. Envuelve los envíos en try/catch, maneja
onErrory acompaña los pings de una terminación real de las conexiones que no responden. - Autentica durante el handshake — pasa tokens de autenticación en la query string o en cookies durante la petición de upgrade. No intentes autenticar por el canal de mensajes después de conectar; el handshake es el punto más seguro. Y valida la cabecera
Origin: los navegadores no aplican same-origin a los WebSockets, así que un endpoint sin validar invita al cross-site WebSocket hijacking. - Valida todos los mensajes entrantes — los payloads WebSocket no son de confianza. Valida la entrada, impone límites de tamaño y aplica rate limiting por cliente; un mensaje malformado o gigante no debería poder tirar tu handler.
- Usa salas para entrega dirigida — organiza los clientes en salas/canales según la lógica de aplicación (salas de chat, IDs de documento, grupos de usuarios) en lugar de difundir a todos. Reduce la carga del servidor y el filtrado del lado del cliente.
- Implementa lógica de reconexión en el cliente — los navegadores no reconectan solos. Envuelve la instancia de
WebSocketen un gestor con backoff exponencial más jitter, y vuelve a unirte a las salas tras reconectar: el servidor ya las olvidó.
Errores Comunes
- Difundir cada mensaje a todos los clientes conectados en lugar de usar salas — ancho de banda innecesario y ruido del lado del cliente.
- Hacer ping sin comprobar los pongs. Un bucle de
pingpor sí solo no detecta conexiones muertas; hace falta un flag (isAlive) y unterminate()para los clientes que dejan de responder. - Enviar datos binarios sin verificar
ws.binaryTypeen el cliente, lo que produce confusión entreBlobyArrayBufferen JavaScript. - Asumir que un WebSocket está autenticado porque pasó por un endpoint HTTP autenticado. El upgrade es una petición separada; verifica el token en el propio handshake.
- Olvidar la contrapresión (backpressure). Si un cliente consume mensajes con lentitud, el encolamiento ilimitado en el servidor acaba agotando la memoria: vigila
ws.bufferedAmount, pausa los eventos efímeros como los indicadores de escritura y desconecta a los clientes que nunca drenan. - Reconectar con un retardo fijo. Cuando un servidor reinicia, todos los clientes se reconectan en el mismo segundo y la estampida lo tumba otra vez: el backoff exponencial con jitter reparte la carga.
Lecturas recomendadas
- RFC 6455 — El protocolo WebSocket
- MDN — La API WebSocket
- Documentación de python-websockets
- ws — librería WebSocket para Node.js
- Documentación de Socket.IO
- Proyecto de chat ejecutable — repo companion con una app de chat completa (salas, presencia, fan-out por Redis, cliente React) construida sobre esta receta.
Preguntas frecuentes
¿Cuántas conexiones WebSocket concurrentes puede manejar un servidor?
Depende del lenguaje, el framework y el hardware. Node.js con ws puede manejar entre 10.000 y 50.000 conexiones en un único proceso. Python con websockets (asyncio) suele manejar entre 1.000 y 10.000. Java (Netty/Spring) puede escalar a más de 100.000 con el ajuste adecuado. El escalado horizontal con afinidad de sesión o pub/sub compartido (Redis) es necesario para despliegues realmente masivos.
¿Cuál es la diferencia entre ws:// y wss://?
ws:// va sin cifrar; wss:// tunela WebSocket sobre TLS, igual que http:// frente a https://. Los navegadores bloquean ws:// como contenido mixto cuando la página se sirvió por HTTPS, así que en producción hace falta wss://, lo que implica terminar TLS en el balanceador o en el servidor.
¿Debo usar WebSocket puro o una librería como Socket.IO?
Usa WebSocket puro si necesitas máximo control, sobrecarga mínima o tramas a medida. Usa Socket.IO si necesitas respaldo automático a HTTP long-polling (para proxies que bloquean WebSocket), gestión de salas integrada, lógica de reconexión y heartbeats. Socket.IO añade unos 20 KB del lado del cliente y algo de sobrecarga de protocolo.
¿Cómo escalo servidores WebSocket horizontalmente?
No puedes difundir entre múltiples instancias de servidor sin un bus de mensajes compartido. Usa Redis Pub/Sub, RabbitMQ o un servicio gestionado (AWS API Gateway, Pusher, Ably) para repartir mensajes entre todas las instancias. Los clientes conectados al servidor A deben recibir los mensajes que envían los del servidor B a través de ese bus compartido.
¿Por qué mi WebSocket se desconecta tras un minuto de inactividad?
Casi siempre es un proxy, un gateway NAT o un balanceador de carga que cierra conexiones inactivas — los timeouts de inactividad típicos van de 30 a 120 segundos. La solución son tramas ping/pong cada ~30 segundos para que la conexión parezca viva ante los middleboxes, más lógica de reconexión en el cliente para las caídas que igualmente se cuelan.
¿Puedo usar WebSocket sobre HTTP/2?
El RFC 8441 define un handshake de WebSocket sobre HTTP/2, pero el soporte en servidores y proxies sigue siendo escaso, así que en la práctica WebSocket corre sobre HTTP/1.1 Upgrade. Cuando el requisito es push nativo sobre HTTP/2, Server-Sent Events ya corre sobre HTTP/2 para entrega servidor → cliente.
¿WebSocket garantiza el orden de los mensajes?
Dentro de una misma conexión, sí: los frames llegan en el orden en que se enviaron. El problema son las reconexiones y el fan-out multi-servidor, donde un backlog retransmitido puede intercalarse con mensajes en vivo; un número de secuencia del lado del servidor más un búfer en el cliente es la solución habitual.
¿Cómo sé quién está online (presencia)?
Guarda un Map<userId, Set<socket>> con las conexiones vivas por usuario y difunde presence:online/presence:offline a sus salas. Marca al usuario como offline solo cuando se cierra su último socket: alguien con dos pestañas no debería parpadear al cerrar una. El heartbeat detecta las conexiones que mueren sin un frame de cierre limpio.
¿Cómo pruebo conexiones WebSocket en CI?
Usa el cliente de ws en tests de Node.js: conecta, envía un mensaje y verifica que llega la difusión. mock-socket simula el servidor en tests unitarios sin abrir un puerto, y Artillery o k6 tienen escenarios WebSocket para probar miles de conexiones concurrentes.
Recursos Relacionados
Server-Sent Events (SSE): Streaming en Tiempo Real
Implementá streaming unidireccional en tiempo real del servidor al navegador con Server-Sent Events. Cubre Python, Node.js, Java, tipos de eventos, reconexión y broadcasting.
RecipeWebhooks: Notificaciones HTTP
Cómo crear y consumir endpoints de webhook para integraciones event-driven en tiempo real.
RecipeAutenticacion y Patrones de Seguridad para WebSockets
Como autenticar conexiones WebSocket, implementar validacion de tokens y manejar autorizacion para mensajeria en tiempo real en produccion
PatternPatrón Publish-Subscribe
Difunde eventos a múltiples suscriptores independientes. Los publicadores envían mensajes a un tópico sin saber quién escucha, manteniendo productores y consumidores desacoplados.
RecipeConstruir notificaciones en tiempo real con WebSockets
Implementa un sistema de notificaciones en tiempo real usando WebSockets y Redis pub/sub para difundir mensajes entre clientes.
RecipeConstruir APIs en Tiempo Real con WebSockets en Serverless
Cómo implementar comunicación bidireccional en tiempo real usando WebSockets con AWS API Gateway, Lambda, DynamoDB y lo que funciona en gestión de conexiones.