Hospedando WebSocket: conexões longas, proxy e escalabilidade

Um servidor WebSocket precisa de um processo sempre ligado e de um proxy que repasse o cabeçalho Upgrade. Envie pings periódicos para as conexões não caírem por inatividade e use TLS (wss://).

Atualizado em 6 min de leitura

Objetivos de aprendizado

Depois de ler este artigo, você vai conseguir:

  • Subir um servidor WebSocket em Node.js com heartbeat e encerramento limpo
  • Configurar o proxy reverso para aceitar o Upgrade e não cortar conexões ociosas
  • Planejar reconexão no cliente e escala com mais de uma instância
Nesta página

Um servidor HTTP comum atende uma requisição e esquece o cliente. Um servidor WebSocket mantém centenas ou milhares de conexões abertas ao mesmo tempo, cada uma por minutos ou horas. Isso muda o que importa na hospedagem: o processo não pode dormir, o proxy não pode cortar conexões ociosas e a memória cresce com o número de clientes conectados.

Este guia mostra como subir um servidor com a biblioteca ws, colocá-lo atrás de um proxy e pensar na escala. Se você ainda não conhece o protocolo, comece por o que é um WebSocket.

Cliente ACliente Bwss://14ProxyreversorepassaUpgradeInstância 12publicaRedispub/sub3entregaInstância 2
Cada conexão fica presa a uma instância; o publish/subscribe do Redis leva a mensagem às outras.

O que um servidor WebSocket exige da hospedagem?#

Quatro coisas:

  • Processo sempre ligado. Plataformas serverless que encerram o processo após cada requisição não servem, porque a conexão morre junto. Veja serverless vs containers.
  • Proxy que entenda o Upgrade. O handshake é uma requisição GET HTTP/1.1 com Upgrade: websocket e Connection: Upgrade. O servidor responde 101 Switching Protocols e, a partir daí, a conexão deixa de ser HTTP.
  • TLS. Uma página servida por HTTPS só pode abrir WebSockets seguros. Em produção, o cliente abre wss://.
  • Memória proporcional às conexões. Cada conexão guarda buffers e o estado que você anexar a ela. Meça com carga antes de escolher o tamanho do servidor.

Como escrever o servidor?#

Instale a dependência (o código usa módulos ES, então defina "type": "module" no package.json):

bash
npm install ws

O servidor abaixo escuta numa porta vinda do ambiente, faz broadcast das mensagens, detecta conexões mortas com ping/pong e encerra com limpeza quando recebe SIGTERM:

js
import { createServer } from "node:http";
import { WebSocketServer, WebSocket } from "ws";

const port = Number(process.env.PORT ?? 3000);

const server = createServer((req, res) => {
  // Rota HTTP simples para health check
  res.writeHead(200, { "content-type": "text/plain" });
  res.end("ok");
});

const wss = new WebSocketServer({
  server,
  path: "/ws",
  maxPayload: 1024 * 1024, // 1 MiB; o padrão da ws é 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 a cada 30 s: mantém o proxy satisfeito e remove conexões mortas
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": o cliente sabe que deve reconectar
  for (const ws of wss.clients) ws.close(1001, "server restarting");
  wss.close();
  server.close(() => process.exit(0));
});

server.listen(port, () => console.log(`Escutando na porta ${port}`));

Dois detalhes importam. O maxPayload limita o tamanho de cada mensagem; o padrão da ws é 100 MiB, o que é muito para um chat. E o heartbeat segue o README da própria biblioteca: sem ele, uma conexão cujo cliente sumiu (celular sem sinal, cabo desconectado) continua aparecendo como aberta no servidor.

Como verificar que funciona?#

Teste localmente com o wscat:

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

Abra dois terminais, escreva uma mensagem em um e veja chegar no outro. Depois repita contra a URL pública com wss://. Se a conexão falhar com Unexpected server response: 400 ou 200, o proxy provavelmente não está repassando o Upgrade (próxima seção).

Como configurar o proxy reverso?#

O proxy reverso recebe o tráfego público e repassa ao seu processo. Para WebSocket, ele precisa copiar os cabeçalhos de Upgrade. Exemplo baseado na documentação do nginx:

nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 443 ssl;
    server_name exemplo.com;
    ssl_certificate     /etc/letsencrypt/live/exemplo.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/exemplo.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;
    }
}

Em versões do nginx anteriores à 1.29.7, adicione também proxy_http_version 1.1; no bloco location. O ponto crítico é o tempo limite: por padrão, o nginx fecha a conexão se o servidor ficar 60 segundos sem enviar dados. Duas defesas se combinam: aumentar proxy_read_timeout e manter o ping do servidor em intervalo menor que ele. Com ping a cada 30 s e timeout de 120 s, a perda de um ping não derruba ninguém. Balanceadores de nuvem têm limites parecidos, então leia o timeout da camada que você usa.

Como o cliente deve reagir quando a conexão cai?#

Conexões longas caem: deploy, reinício, troca de rede no celular. O cliente precisa reconectar sozinho, com espera crescente para não derrubar o servidor com uma enxurrada de tentativas no mesmo instante:

js
function connect(url, onMessage) {
  let attempt = 0;

  function open() {
    const ws = new WebSocket(url); // nativo no navegador e nas versões atuais do Node.js

    ws.addEventListener("open", () => {
      attempt = 0;
    });
    ws.addEventListener("message", (event) => onMessage(event.data));
    ws.addEventListener("close", () => {
      // 1 s, 2 s, 4 s... até 30 s, com variação aleatória
      const base = Math.min(1000 * 2 ** attempt++, 30_000);
      setTimeout(open, base / 2 + Math.random() * (base / 2));
    });
  }

  open();
}

connect("wss://exemplo.com/ws", (data) => console.log(data));

A variação aleatória (jitter) importa: se mil clientes perdem a conexão no mesmo reinício, sem ela todos tentam voltar no mesmo milissegundo. Depois de reconectar, o cliente também deve pedir ao servidor as mensagens que perdeu, porque o WebSocket não reenvia nada sozinho.

Como escalar para mais de uma instância?#

Uma instância aguenta bastante, mas chega a hora em que você precisa de duas, por capacidade ou por redundância. O problema: cada conexão vive em um único processo. Se o usuário A está na instância 1 e o usuário B na instância 2, um broadcast na instância 1 nunca chega ao B.

A solução comum é um barramento compartilhado. Cada instância se inscreve em um canal de publish/subscribe do Redis; quando recebe uma mensagem de um cliente, publica no canal, e todas as instâncias (inclusive a própria) a entregam aos seus clientes locais. O publish/subscribe do Redis entrega cada mensagem no máximo uma vez: quem estiver desconectado naquele instante a perde. Assim, qualquer instância atende qualquer cliente, e o load balancer pode distribuir as conexões como quiser.

Duas regras ajudam. Primeiro, mantenha o servidor sem estado de sessão local: guarde o que precisa sobreviver a uma queda no banco ou no Redis. Segundo, trate a lista de conexões como descartável, porque num deploy todas as conexões são recriadas.

Quais erros aparecem com mais frequência?#

  • Conexão cai sempre no mesmo intervalo (60 s, por exemplo): timeout de proxy. Envie pings mais frequentes.
  • 400 Bad Request no handshake: o proxy removeu Upgrade ou Connection, ou o cabeçalho Sec-WebSocket-Key não chegou.
  • Navegador bloqueia ws:// numa página HTTPS: use wss://.
  • Memória cresce sem parar: conexões mortas não removidas (falta heartbeat) ou referências guardadas em mapas globais depois do close.
  • Funciona local e falha em produção: confira a porta escutada, o caminho (/ws) e se o proxy aceita Upgrade nesse caminho.
  • Origem não verificada: navegadores sempre enviam Origin. Valide-o no servidor (resposta 403) para evitar que outros sites abram conexões em nome do usuário. Clientes fora do navegador podem falsificar esse cabeçalho, então autentique também com token.

Como fazer isso na Square Cloud#

Na Square Cloud, a aplicação roda em um container isolado que fica sempre ligado, sem modo de suspensão, o que atende o primeiro requisito de um servidor WebSocket. Com o reinício automático (ligado por padrão no deploy pelo painel), ela volta sozinha quando o processo encerra com erro. A rede da plataforma tem suporte a WebSockets e HTTP/3, com proteção DDoS, e o proxy não corta conexões longas nem faz buffer delas. Toda aplicação web recebe um subdomínio *.squareweb.app com HTTPS, então o cliente conecta com wss:// sem você configurar proxy ou certificado.

Você faz o deploy: envie o código por ZIP, CLI, GitHub ou extensão do VS Code e escolha o runtime (Node.js, Python, Go e outros). O arquivo squarecloud.app é opcional, porque o painel o gera no deploy com runtime, memória e arquivo principal. As variáveis de ambiente ficam no painel e valem no próximo restart, e os logs em tempo real mostram conexões e erros. O exemplo já escuta na porta da variável PORT, que a plataforma define; mantenha o servidor aceitando conexões em 0.0.0.0.

A RAM do plano é um pool compartilhado entre as aplicações, com mínimo de 512 MB por aplicação web, então meça o consumo com clientes reais para dimensionar. Não há autoscaling: para crescer na vertical, você mesmo altera a RAM da aplicação.

Para mais de uma instância, o load balancing distribui um domínio personalizado entre várias aplicações com round-robin e failover passivo. Como não há sticky session, cada reconexão pode cair em outra instância. A hospedagem de Redis (a partir do Standard) aceita PUBLISH/SUBSCRIBE e pode servir de barramento entre elas. Veja mais em hospedagem de APIs.

Hospedagem de APIs

APIs em containers sempre ligados, sem cold start

Saiba mais

Perguntas frequentes

Preciso de uma porta separada para o WebSocket?

Não. O WebSocket começa como uma requisição HTTP e é promovido por Upgrade, então pode compartilhar a porta do seu servidor HTTP. Usar portas diferentes de 80 e 443 pode causar problemas com firewalls e proxies.

Por que minha conexão cai depois de 60 segundos sem mensagens?

Geralmente é o proxy no meio do caminho. O nginx, por exemplo, fecha a conexão se o servidor não enviar dados em 60 segundos (proxy_read_timeout). Enviar pings do servidor a cada 30 segundos resolve.

WebSocket funciona atrás de um load balancer?

Funciona, mas cada conexão fica presa a uma instância. Mensagens para clientes em instâncias diferentes exigem um barramento compartilhado, como o publish/subscribe do Redis.

Devo usar ws:// ou wss:// em produção?

Use wss://, que é o WebSocket sobre TLS. Uma página HTTPS não pode abrir ws:// (o navegador bloqueia como conteúdo misto), e o tráfego sem TLS pode ser lido no caminho.

Próximo artigoO que é uma API RESTUma API REST expõe recursos por URLs e os manipula com métodos HTTP como GET e POST. Veja as regras, os códigos de status e um exemplo que roda.