Cómo hospedar un servidor WebSocket: conexiones largas, proxy y escalado

Un servidor WebSocket necesita un proceso siempre activo y un proxy que reenvíe la cabecera Upgrade. Envía pings periódicos para que las conexiones inactivas no se corten y usa TLS (wss://).

Actualizado el 6 min de lectura

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

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.

Cliente ACliente Bwss://14Proxyinversopasa elUpgradeInstancia 12publicaRedispub/sub3entregaInstancia 2
Cada conexión queda atada a una instancia; el publish/subscribe de Redis lleva el mensaje a las demás.

¿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 GET HTTP/1.1 con Upgrade: websocket y Connection: Upgrade. El servidor responde 101 Switching Protocols y, 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):

bash
npm install ws

El 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:

js
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:

bash
npx wscat -c ws://localhost:3000/ws

Abre 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:

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:

js
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 Request en el handshake: el proxy eliminó Upgrade o Connection, o la cabecera Sec-WebSocket-Key no llegó.
  • El navegador bloquea ws:// en una página HTTPS: usa wss://.
  • 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 (respuesta 403) 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

Más información

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.

Siguiente artículoQué es una API RESTUna API REST expone recursos en URLs y los manipula con métodos HTTP como GET y POST. Conoce las reglas, los códigos de estado y un ejemplo que funciona.