Objetivos de aprendizaje
Después de leer este artículo podrás:
- Levantar un servidor WebSocket en Node.js con heartbeat y cierre limpio
- Configurar el proxy inverso para aceptar el Upgrade y no cortar conexiones inactivas
- Planificar la reconexión en el cliente y el escalado con más de una instancia
En esta página
- ¿Qué exige un servidor WebSocket al hosting?
- ¿Cómo escribes el servidor?
- ¿Cómo compruebas que funciona?
- ¿Cómo configuras el proxy inverso?
- ¿Cómo debe reaccionar el cliente cuando se cae la conexión?
- ¿Cómo escalas a más de una instancia?
- ¿Qué errores aparecen con más frecuencia?
- Cómo hacerlo en Square Cloud
- Preguntas frecuentes
Un servidor HTTP normal atiende una petición y se olvida del cliente. Un servidor WebSocket mantiene cientos o miles de conexiones abiertas a la vez, cada una durante minutos u horas. Eso cambia lo que importa en el hosting: el proceso no puede dormirse, el proxy no puede cortar conexiones inactivas y la memoria crece con el número de clientes conectados.
Esta guía muestra cómo levantar un servidor con la biblioteca ws, ponerlo detrás de un proxy y pensar en el escalado. Si todavía no conoces el protocolo, empieza por qué es un WebSocket.
¿Qué exige un servidor WebSocket al hosting?#
Cuatro cosas:
- Proceso siempre activo. Las plataformas serverless que terminan el proceso después de cada petición no sirven, porque la conexión muere con él. Consulta serverless vs contenedores.
- Un proxy que entienda el Upgrade. El handshake es una petición
GETHTTP/1.1 conUpgrade: websocketyConnection: Upgrade. El servidor responde101 Switching Protocolsy, a partir de ahí, la conexión deja de ser HTTP. - TLS. Una página servida por HTTPS solo puede abrir WebSockets seguros. En producción, el cliente abre
wss://. - Memoria proporcional a las conexiones. Cada conexión guarda buffers y el estado que le asocies. Mide con carga antes de elegir el tamaño del servidor.
¿Cómo escribes el servidor?#
Instala la dependencia (el código usa módulos ES, así que define "type": "module" en package.json):
npm install wsEl servidor de abajo escucha en un puerto que viene del entorno, hace broadcast de los mensajes, detecta conexiones muertas con ping/pong y se cierra limpiamente cuando recibe SIGTERM:
import { createServer } from "node:http";
import { WebSocketServer, WebSocket } from "ws";
const port = Number(process.env.PORT ?? 3000);
const server = createServer((req, res) => {
// Ruta HTTP simple para el health check
res.writeHead(200, { "content-type": "text/plain" });
res.end("ok");
});
const wss = new WebSocketServer({
server,
path: "/ws",
maxPayload: 1024 * 1024, // 1 MiB; el valor por defecto de ws es 100 MiB
});
function heartbeat() {
this.isAlive = true;
}
wss.on("connection", (ws) => {
ws.isAlive = true;
ws.on("pong", heartbeat);
ws.on("error", console.error);
ws.on("message", (data, isBinary) => {
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
}
}
});
});
// Ping cada 30 s: mantiene contento al proxy y elimina conexiones muertas
const interval = setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) {
ws.terminate();
continue;
}
ws.isAlive = false;
ws.ping();
}
}, 30_000);
wss.on("close", () => clearInterval(interval));
process.on("SIGTERM", () => {
// 1001 = "going away": el cliente sabe que debe reconectarse
for (const ws of wss.clients) ws.close(1001, "server restarting");
wss.close();
server.close(() => process.exit(0));
});
server.listen(port, () => console.log(`Escuchando en el puerto ${port}`));Dos detalles importan. maxPayload limita el tamaño de cada mensaje; el valor por defecto de ws es 100 MiB, demasiado para un chat. Y el heartbeat sigue el README de la propia biblioteca: sin él, una conexión cuyo cliente desapareció (móvil sin cobertura, cable desconectado) sigue figurando como abierta en el servidor.
¿Cómo compruebas que funciona?#
Prueba en local con wscat:
npx wscat -c ws://localhost:3000/wsAbre dos terminales, escribe un mensaje en una y mira cómo llega a la otra. Después repite contra la URL pública con wss://. Si la conexión falla con Unexpected server response: 400 o 200, probablemente el proxy no está reenviando el Upgrade (siguiente sección).
¿Cómo configuras el proxy inverso?#
El proxy inverso recibe el tráfico público y lo reenvía a tu proceso. Para WebSocket, tiene que copiar las cabeceras de Upgrade. Ejemplo basado en la documentación de nginx:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name ejemplo.com;
ssl_certificate /etc/letsencrypt/live/ejemplo.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ejemplo.com/privkey.pem;
location /ws {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 120s;
}
}En versiones de nginx anteriores a la 1.29.7, añade también proxy_http_version 1.1; en el bloque location. El punto crítico es el tiempo de espera: por defecto, nginx cierra la conexión si el servidor pasa 60 segundos sin enviar datos. Dos defensas se combinan: subir proxy_read_timeout y mantener el ping del servidor en un intervalo menor. Con ping cada 30 s y timeout de 120 s, perder un ping no tumba a nadie. Los balanceadores de la nube tienen límites parecidos, así que revisa el timeout de la capa que uses.
¿Cómo debe reaccionar el cliente cuando se cae la conexión?#
Las conexiones largas se caen: deploys, reinicios, cambios de red en el móvil. El cliente tiene que reconectarse solo, con una espera creciente para no tumbar el servidor con una avalancha de intentos en el mismo instante:
function connect(url, onMessage) {
let attempt = 0;
function open() {
const ws = new WebSocket(url); // nativo en el navegador y en las versiones actuales de Node.js
ws.addEventListener("open", () => {
attempt = 0;
});
ws.addEventListener("message", (event) => onMessage(event.data));
ws.addEventListener("close", () => {
// 1 s, 2 s, 4 s... hasta 30 s, con variación aleatoria
const base = Math.min(1000 * 2 ** attempt++, 30_000);
setTimeout(open, base / 2 + Math.random() * (base / 2));
});
}
open();
}
connect("wss://ejemplo.com/ws", (data) => console.log(data));La variación aleatoria (jitter) importa: si mil clientes pierden la conexión en el mismo reinicio, sin ella todos intentan volver en el mismo milisegundo. Después de reconectarse, el cliente también debe pedir al servidor los mensajes que se perdió, porque WebSocket no reenvía nada por sí solo.
¿Cómo escalas a más de una instancia?#
Una instancia aguanta bastante, pero llega el momento en que necesitas dos, por capacidad o por redundancia. El problema: cada conexión vive en un único proceso. Si el usuario A está en la instancia 1 y el usuario B en la instancia 2, un broadcast en la instancia 1 nunca llega a B.
La solución habitual es un bus compartido. Cada instancia se suscribe a un canal de publish/subscribe de Redis; cuando recibe un mensaje de un cliente, lo publica en el canal, y todas las instancias (incluida ella misma) lo entregan a sus clientes locales. El publish/subscribe de Redis entrega cada mensaje como máximo una vez: quien esté desconectado en ese momento lo pierde. Así, cualquier instancia atiende a cualquier cliente, y el balanceador de carga puede repartir las conexiones como quiera.
Dos reglas ayudan. Primero, mantén el servidor sin estado de sesión local: guarda lo que tenga que sobrevivir a una caída en la base de datos o en Redis. Segundo, trata la lista de conexiones como desechable, porque en un deploy todas las conexiones se vuelven a crear.
¿Qué errores aparecen con más frecuencia?#
- La conexión se corta siempre en el mismo intervalo (60 s, por ejemplo): timeout del proxy. Envía pings más a menudo.
400 Bad Requesten el handshake: el proxy eliminóUpgradeoConnection, o la cabeceraSec-WebSocket-Keyno llegó.- El navegador bloquea
ws://en una página HTTPS: usawss://. - La memoria crece sin parar: conexiones muertas que no se eliminan (falta el heartbeat) o referencias guardadas en mapas globales después del
close. - Funciona en local y falla en producción: revisa el puerto en el que escuchas, la ruta (
/ws) y si el proxy acepta Upgrade en esa ruta. - Origen sin verificar: los navegadores siempre envían
Origin. Valídalo en el servidor (respuesta403) para evitar que otros sitios abran conexiones en nombre del usuario. Los clientes fuera del navegador pueden falsificar esa cabecera, así que autentica también con un token.
Cómo hacerlo en Square Cloud#
En Square Cloud, la aplicación se ejecuta en un contenedor aislado que está siempre encendido, sin modo de suspensión, lo que cubre el primer requisito de un servidor WebSocket. Con el reinicio automático (activado por defecto en el deploy desde el panel), vuelve sola cuando el proceso termina con error. La red de la plataforma admite WebSockets y HTTP/3, con protección DDoS, y el proxy no corta las conexiones largas ni les hace buffer. Cada aplicación web recibe un subdominio *.squareweb.app con HTTPS, así que el cliente se conecta con wss:// sin que configures proxy ni certificado.
El deploy lo haces tú: envía el código por ZIP, CLI, GitHub o la extensión de VS Code y elige el runtime (Node.js, Python, Go y otros). El archivo squarecloud.app es opcional, porque el panel lo genera al desplegar con el runtime, la memoria y el archivo principal. Las variables de entorno se definen en el panel y se aplican en el siguiente reinicio, y los logs en tiempo real muestran conexiones y errores. El ejemplo ya escucha en el puerto de la variable PORT, que define la plataforma; mantén el servidor aceptando conexiones en 0.0.0.0.
La RAM del plan es un pool compartido entre las aplicaciones, con un mínimo de 512 MB por aplicación web, así que mide el consumo con clientes reales para dimensionar. No hay autoscaling: para escalar en vertical, cambias tú mismo la RAM de la aplicación.
Para más de una instancia, el load balancing reparte un dominio personalizado entre varias aplicaciones con round-robin y failover pasivo. Como no hay sticky session, cada reconexión puede caer en otra instancia. El hosting de Redis (desde el plan Standard) acepta PUBLISH/SUBSCRIBE y puede servir de bus entre ellas. Más información en hosting de APIs.
Hosting de APIs
APIs en contenedores siempre encendidos, sin cold start
Preguntas frecuentes
¿Necesito un puerto aparte para el WebSocket?
No. El WebSocket empieza como una petición HTTP y se promueve con Upgrade, así que puede compartir el puerto de tu servidor HTTP. Usar puertos distintos de 80 y 443 puede causar problemas con firewalls y proxies.
¿Por qué mi conexión se corta tras 60 segundos sin mensajes?
Normalmente es el proxy que está en medio. nginx, por ejemplo, cierra la conexión si el servidor no envía datos en 60 segundos (proxy_read_timeout). Enviar pings desde el servidor cada 30 segundos lo resuelve.
¿WebSocket funciona detrás de un balanceador de carga?
Funciona, pero cada conexión queda atada a una instancia. Los mensajes para clientes en instancias distintas exigen un bus compartido, como el publish/subscribe de Redis.
¿Debo usar ws:// o wss:// en producción?
Usa wss://, que es WebSocket sobre TLS. Una página HTTPS no puede abrir ws:// (el navegador lo bloquea como contenido mixto), y el tráfico sin TLS puede leerse por el camino.
Más en APIs
Ver los 15 artículos- Cómo hospedar una APIHospedar una API es ejecutar su servidor HTTP las 24 horas en una dirección pública con HTTPS. Guía paso a paso con Node.js y Python, pruebas y errores comunes.
- Cómo hospedar una aplicación Node.jsPara hospedar una aplicación Node.js, prepara el package.json, lee el puerto y los secretos del entorno, elige una versión LTS y ejecútala con node. Paso a paso.
- Cómo hospedar FastAPI, Flask o DjangoPara hospedar FastAPI, Flask o Django, ejecuta la aplicación con un servidor de producción (Uvicorn o Gunicorn) en 0.0.0.0. Comandos, ajustes de Django y errores.
- Cómo hospedar una app o script de PythonPara hospedar una app o script de Python, fija la versión, aísla las dependencias en un venv, lee los secretos del entorno y maneja SIGTERM. Guía paso a paso.