Arquitetando Comunicação em Tempo Real em Escala: Redis Pub/Sub, Load Balancing e Milhares de Conexões Simultâneas
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);
---
Leia o artigo completo em [https://www.vivodecodigo.com.br/backend/websockets-escala-redis-pubsub-load-balancing-conexoes-simultaneas](https://www.vivodecodigo.com.br/backend/websockets-escala-redis-pubsub-load-balancing-conexoes-simultaneas)