Arquitetando Comunicação em Tempo Real em Escala: Redis Pub/Sub, Load Balancing e Milhares de Conexões Simultâneas

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O problema que aparece quando a segunda instância sobe
Uma única instância de Node.js com a biblioteca ws segura 10 mil conexões WebSocket sem esforço. O event loop do V8, combinado com a libuv gerenciando I/O não-bloqueante, mantém o consumo de memória por conexão abaixo de 50KB. O problema nunca é a primeira instância. É a segunda.
Quando o load balancer distribui conexões entre duas ou mais instâncias, um usuário conectado à instância A não recebe mensagens publicadas na instância B. Cada processo Node.js mantém seu próprio mapa de conexões em memória. Sem um canal externo de coordenação, as instâncias são ilhas.
A solução padrão da indústria é usar Redis Pub/Sub como backbone de distribuição. Cada instância subscreve a canais Redis e retransmite mensagens para seus clientes locais. É simples, mas os detalhes de implementação determinam se o sistema aguenta 500 ou 50 mil conexões.
Arquitetura geral: o que conecta com o quê
O fluxo completo funciona assim:
Cliente WebSocket
│
▼
┌─────────────────┐
│ Load Balancer │ (Nginx / ALB com sticky sessions)
│ (Layer 7) │
└────┬───────┬─────┘
│ │
▼ ▼
┌────────┐ ┌────────┐
│ Node A │ │ Node B │ (cada um com servidor WebSocket)
│ ws │ │ ws │
└───┬────┘ └───┬────┘
│ │
▼ ▼
┌──────────────────┐
│ Redis Pub/Sub │ (canal de coordenação entre instâncias)
└──────────────────┘Quando o Node A recebe uma mensagem de um cliente que pertence a uma sala, ele publica no Redis. O Node B, subscrito ao mesmo canal, recebe e retransmite para os clientes locais daquela sala. O Redis não armazena mensagens: é fire-and-forget. Se uma instância estiver offline no momento da publicação, perde a mensagem. Isso é intencional para tempo real, onde latência importa mais que garantia de entrega. Se você precisa de entrega garantida, o caminho é mensageria com BullMQ e Redis.
Servidor WebSocket com Redis Pub/Sub
O código abaixo usa ws (não Socket.IO) porque o overhead de protocolo é menor e o controle sobre a conexão é total. Socket.IO adiciona fallback para polling, reconexão automática e namespaces, mas para cenários onde o cliente é controlado (SPA própria, app mobile), ws puro resolve com menos abstração.
// src/server.ts
import { WebSocketServer, WebSocket } from "ws";
import { createClient } from "redis";
import { createServer } from "http";
const PORT = Number(process.env.PORT) || 3000;
const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
// Mapa local de salas: cada instância só conhece seus próprios clientes
const rooms = new Map<string, Set<WebSocket>>();
const httpServer = createServer();
const wss = new WebSocketServer({ server: httpServer });
// Dois clientes Redis separados: um para publish, outro para subscribe.
// O cliente em modo subscriber não aceita comandos regulares (GET, SET, etc).
const publisher = createClient({ url: REDIS_URL });
const subscriber = createClient({ url: REDIS_URL });
async function bootstrap() {
await publisher.connect();
await subscriber.connect();
// Subscreve a um padrão de canais para todas as salas
await subscriber.pSubscribe("room:*", (message, channel) => {
const roomId = channel.replace("room:", "");
const clients = rooms.get(roomId);
if (!clients) return;
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
});
wss.on("connection", (ws, req) => {
// Extrai roomId da query string: ws://host:3000/?room=abc123
const url = new URL(req.url || "/", `http://${req.headers.host}`);
const roomId = url.searchParams.get("room");
if (!roomId) {
ws.close(4000, "room query param required");
return;
}
// Adiciona ao mapa local
if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
}
rooms.get(roomId)!.add(ws);
ws.on("message", (data) => {
const payload = data.toString();
// Publica no Redis para que TODAS as instâncias recebam
publisher.publish(`room:${roomId}`, payload);
});
ws.on("close", () => {
const clients = rooms.get(roomId);
if (clients) {
clients.delete(ws);
if (clients.size === 0) rooms.delete(roomId);
}
});
});
httpServer.listen(PORT, () => {
console.log(`WebSocket server on port ${PORT}`);
});
}
bootstrap();Dois detalhes que passam despercebidos:
-
O
pSubscribecom padrãoroom:*evita subscrever/dessubscrever dinamicamente a cada entrada e saída de sala. O custo é receber mensagens de salas sem clientes locais, mas oif (!clients) returndescarta rápido. -
Cada instância recebe sua própria mensagem de volta via Redis. Se isso for um problema (mensagem duplicada para o remetente), filtre pelo ID do remetente no payload.
Configurando Nginx com sticky sessions para WebSocket
WebSocket começa como HTTP upgrade. Se o load balancer enviar o request de upgrade para uma instância e os frames subsequentes para outra, a conexão quebra. Sticky sessions resolvem isso vinculando um cliente a uma instância específica.
# /etc/nginx/conf.d/websocket.conf
upstream websocket_nodes {
# ip_hash garante que o mesmo IP sempre vá para o mesmo backend.
# Alternativa: usar cookie sticky (módulo nginx-sticky-module).
ip_hash;
server 10.0.1.10:3000;
server 10.0.1.11:3000;
server 10.0.1.12:3000;
}
server {
listen 80;
server_name ws.example.com;
location / {
proxy_pass http://websocket_nodes;
# Headers obrigatórios para WebSocket upgrade
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Timeout de 1h para conexões idle.
# O default de 60s fecha WebSockets que ficam sem tráfego.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}Se você usa AWS ALB, sticky sessions são configuradas no target group com cookie de duração. O ALB suporta WebSocket nativamente na Layer 7, mas o timeout padrão de idle connection é 60 segundos. Ajuste para o máximo (4000s) no console ou via CLI:
aws elbv2 modify-target-group-attributes \
--target-group-arn arn:aws:elasticloadbalancing:us-east-1:123456789:targetgroup/ws-targets/abc123 \
--attributes Key=stickiness.enabled,Value=true \
Key=stickiness.type,Value=lb_cookie \
Key=stickiness.lb_cookie.duration_seconds,Value=86400 \
Key=deregistration_delay.timeout_seconds,Value=30| Critério | Nginx ip_hash | Nginx sticky cookie | AWS ALB sticky |
|---|---|---|---|
| Funciona atrás de NAT/proxy | Não (IPs compartilhados) | Sim | Sim |
| Requer módulo extra | Não | Sim (nginx-sticky-module) | Não |
| Redistribuição ao escalar | Todas as conexões rehash | Apenas novas conexões | Apenas novas conexões |
| Custo operacional | Baixo | Médio | Gerenciado pela AWS |
Para aplicações atrás de NAT corporativo, ip_hash concentra todos os usuários do mesmo escritório na mesma instância. Use sticky cookie nesses cenários.
Heartbeat: mantendo conexões vivas
Conexões WebSocket morrem silenciosamente. O cliente fecha o laptop, o NAT intermediário expira a sessão, o proxy derruba por idle. Sem heartbeat, o servidor mantém referências a sockets mortos, vazando memória e distorcendo métricas de usuários online.
// src/heartbeat.ts
import { WebSocket } from "ws";
const HEARTBEAT_INTERVAL_MS = 30_000;
const PONG_TIMEOUT_MS = 10_000;
export function setupHeartbeat(ws: WebSocket): NodeJS.Timeout {
let pongReceived = true;
ws.on("pong", () => {
pongReceived = true;
});
const interval = setInterval(() => {
if (!pongReceived) {
// Não recebeu pong do último ping: conexão morta
ws.terminate();
clearInterval(interval);
return;
}
pongReceived = false;
ws.ping();
}, HEARTBEAT_INTERVAL_MS);
ws.on("close", () => clearInterval(interval));
return interval;
}Integre no handler de conexão:
// Dentro do wss.on("connection", ...)
import { setupHeartbeat } from "./heartbeat";
wss.on("connection", (ws, req) => {
setupHeartbeat(ws);
// ... resto do handler
});O intervalo de 30 segundos é conservador o suficiente para não gerar tráfego excessivo, mas rápido o bastante para detectar conexões mortas antes do próximo ciclo de mensagens. Se o cenário exige detecção mais rápida (jogos multiplayer, trading), reduza para 5-10 segundos.
O que NÃO fazer
Anti-pattern 1: armazenar estado de sala no Redis como fonte de verdade
// ERRADO: buscar clientes no Redis a cada mensagem
// Redis não tem referência aos objetos WebSocket da sua instância
async function broadcastToRoom(roomId: string, message: string) {
const members = await redis.sMembers(`room:${roomId}`);
for (const memberId of members) {
// Como você encontra o WebSocket desse memberId?
// Não encontra. O socket vive em memória, não no Redis.
const ws = findSocketById(memberId); // undefined em outra instância
ws?.send(message);
}
}O Redis serve como canal de comunicação entre instâncias, não como registro de quem está conectado onde. O mapa de sockets é local por definição: um objeto WebSocket existe em memória de um processo específico.
// CORRETO: mapa local + Redis Pub/Sub para coordenação
function broadcastToRoom(roomId: string, message: string) {
// Publica no Redis: todas as instâncias recebem
publisher.publish(`room:${roomId}`, message);
}
// O subscriber handler cuida de enviar para clientes locaisSe você precisa saber quantos usuários estão em uma sala (contagem global), mantenha um counter no Redis com INCR/DECR nos eventos de join/leave. Mas não tente mapear socket IDs para instâncias.
Anti-pattern 2: um canal Redis por usuário
// ERRADO: cria canal individual para cada usuário
await subscriber.subscribe(`user:${userId}`, (message) => {
ws.send(message);
});
// Com 10k usuários, são 10k subscriptions no mesmo cliente Redis.
// O throughput do subscriber degrada significativamente acima de ~1000 canais.// CORRETO: use canais por sala/tópico, não por usuário
// Um canal "room:abc" atende 500 usuários da mesma sala com 1 subscription
await subscriber.pSubscribe("room:*", (message, channel) => {
// Distribui localmente
});Se você precisa de mensagens direcionadas a um usuário específico (notificação privada), publique em um canal do tipo direct:{userId} e mantenha um mapa userId -> WebSocket local. O número de canais com pattern subscribe fica gerenciável porque o Redis faz o matching internamente.
Anti-pattern 3: ignorar backpressure
// ERRADO: enviar sem checar se o buffer está cheio
for (const client of clients) {
client.send(hugePayload); // Se o cliente está lento, o buffer cresce até OOM
}// CORRETO: checar bufferedAmount antes de enviar
const MAX_BUFFER_SIZE = 1024 * 1024; // 1MB
for (const client of clients) {
if (client.readyState === WebSocket.OPEN && client.bufferedAmount < MAX_BUFFER_SIZE) {
client.send(payload);
} else if (client.bufferedAmount >= MAX_BUFFER_SIZE) {
// Cliente não está consumindo rápido o suficiente: desconecta
client.terminate();
}
}Clientes em redes lentas (3G, Wi-Fi instável) acumulam dados no buffer de saída do servidor. Sem essa verificação, 50 clientes lentos em uma sala ativa consomem centenas de MB de memória do processo.
Escalando além de uma instância Redis
Redis Pub/Sub em modo standalone é single-threaded para operações de pub/sub. Um único nó Redis processa cerca de 500 mil mensagens por segundo em payloads pequenos (< 1KB). Para a maioria das aplicações de chat, colaboração ou dashboards, isso é suficiente.
Quando não é suficiente, as opções são:
| Solução | Throughput | Persistência | Complexidade |
|---|---|---|---|
| Redis Standalone | ~500k msg/s | Não | Baixa |
| Redis Cluster (sharded channels, Redis 7+) | Linear com shards | Não | Média |
| Redis Streams | ~200k msg/s | Sim (append-only) | Média |
| NATS | ~10M msg/s | Opcional (JetStream) | Média |
| Kafka | ~1M msg/s (por partição) | Sim | Alta |
Redis Streams é uma alternativa interessante quando você precisa de replay (reconexão de clientes que perderam mensagens). A API XREAD com BLOCK permite consumir mensagens novas com semântica de consumer group, algo que Pub/Sub não oferece. Para entender melhor o ecossistema Redis e filas, veja o post sobre mensageria com BullMQ e Redis.
Monitorando conexões em produção
Sem métricas, você descobre que o sistema está no limite quando os usuários reclamam. Exponha contadores básicos via endpoint HTTP na mesma porta ou em porta separada:
// src/metrics.ts
import { WebSocketServer } from "ws";
export function exposeMetrics(wss: WebSocketServer, rooms: Map<string, Set<any>>) {
const httpServer = wss.options.server;
if (!httpServer) return;
// Endpoint de health + métricas básicas
httpServer.on("request", (req, res) => {
if (req.url === "/metrics" && req.method === "GET") {
const totalConnections = wss.clients.size;
const totalRooms = rooms.size;
// Formato compatível com Prometheus
const output = [
`# HELP ws_connections_total Total WebSocket connections`,
`# TYPE ws_connections_total gauge`,
`ws_connections_total ${totalConnections}`,
`# HELP ws_rooms_total Total active rooms`,
`# TYPE ws_rooms_total gauge`,
`ws_rooms_total ${totalRooms}`,
].join("\n");
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(output);
}
});
}Combine com as práticas de observabilidade descritas em microserviços com Node.js para ter traces distribuídos e alertas configurados.
Graceful shutdown sem perder conexões
Quando você faz deploy de uma nova versão, as instâncias antigas precisam drenar conexões antes de morrer. Se o processo simplesmente encerra, todos os clientes conectados perdem a conexão simultaneamente e tentam reconectar ao mesmo tempo, gerando um thundering herd no load balancer.
// src/shutdown.ts
import { WebSocketServer, WebSocket } from "ws";
export function setupGracefulShutdown(wss: WebSocketServer, publisher: any, subscriber: any) {
const shutdown = async () => {
console.log("Shutting down: closing new connections...");
// Para de aceitar novas conexões
wss.close();
// Notifica clientes existentes para reconectar em outro nó
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) {
// Código 4001: reconexão recomendada (custom close code)
client.close(4001, "server_restarting");
}
}
// Aguarda clientes fecharem (max 10s)
await new Promise((resolve) => setTimeout(resolve, 10_000));
await publisher.quit();
await subscriber.quit();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}O código de fechamento 4001 é customizado (range 4000-4999 é reservado para aplicação). O cliente pode tratar esse código especificamente para reconectar imediatamente, sem backoff. Isso se integra bem com estratégias de deploy blue-green e rolling updates, onde o deregistration delay do load balancer dá tempo para a drenagem.
Docker Compose para desenvolvimento local
Para testar múltiplas instâncias localmente, suba três réplicas do servidor com um Redis:
# docker-compose.yml
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
websocket:
build: .
environment:
- REDIS_URL=redis://redis:6379
depends_on:
- redis
deploy:
replicas: 3
nginx:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- websocketDetalhes sobre configuração de Docker para produção estão em Docker para Devs: do Dockerfile ao docker-compose.
Quando Redis Pub/Sub não é a resposta
Redis Pub/Sub funciona bem quando: mensagens são efêmeras (tempo real puro), o número de canais fica abaixo de 10 mil, e a perda eventual de uma mensagem durante redeploy é aceitável.
Não funciona bem quando: você precisa de entrega garantida (use Redis Streams ou BullMQ), precisa de replay de histórico (use Redis Streams com XRANGE), ou o throughput excede 500k msg/s (avalie NATS).
Se a aplicação é um painel de controle com atualizações a cada 5 segundos, Server-Sent Events com polling no backend é mais simples e não exige sticky sessions. WebSocket justifica o custo operacional quando há comunicação bidirecional frequente: chat, colaboração em documento, jogos, trading.
FAQ
Redis Pub/Sub garante entrega de mensagens?
Não. Redis Pub/Sub é fire-and-forget. Se nenhuma instância está subscrita ao canal no momento da publicação, a mensagem é descartada. Se o subscriber está temporariamente desconectado (crash, redeploy), perde tudo que foi publicado nesse intervalo. Para entrega garantida, use Redis Streams com consumer groups ou uma fila como BullMQ.
Quantas conexões WebSocket uma instância Node.js suporta?
Com a biblioteca ws e payloads pequenos (< 1KB), uma instância com 512MB de RAM sustenta entre 20 mil e 50 mil conexões simultâneas, dependendo da frequência de mensagens. O gargalo não é a conexão em si (cada uma consome ~30-50KB), mas o throughput de mensagens. A 1000 msg/s por instância, o event loop do V8 fica confortável. A 50 mil msg/s, monitore o event loop lag.
Preciso de Socket.IO ou ws puro resolve?
Se o cliente é uma SPA ou app mobile que você controla, ws puro resolve com menos overhead. Socket.IO adiciona reconexão automática, fallback para HTTP long-polling, rooms e namespaces nativos. Se você precisa suportar navegadores corporativos antigos com proxies que bloqueiam WebSocket, Socket.IO justifica o overhead. Para aplicações novas onde o browser é moderno, ws com lógica de reconexão no cliente (exponential backoff) é mais previsível.
Como lidar com autenticação em conexões WebSocket?
Valide o token no momento do HTTP upgrade, antes de aceitar a conexão. Extraia o token da query string ou do header Sec-WebSocket-Protocol. Não aceite a conexão para depois validar: isso abre superfície para DoS. Detalhes sobre autenticação com middleware estão em autenticação com NextAuth.js e Middleware e API Gateway patterns.
Redis Cluster funciona com Pub/Sub?
A partir do Redis 7, sim, com sharded Pub/Sub. Antes do Redis 7, PUBLISH em um nó do cluster era replicado para todos os outros nós, o que significava que o throughput não escalava com o número de shards. Com sharded channels (comando SPUBLISH/SSUBSCRIBE), cada canal é atribuído a um shard específico, e o throughput escala linearmente. Se você ainda usa Redis 6, Pub/Sub no cluster funciona, mas sem ganho de throughput.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Guias de integração relacionados
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


