Repositório com os exemplos completos: github.com/LuizFernando991/websocket-scaling
Por que Node.js para conexões em tempo real
Quando você mantém milhares de conexões persistentes, o modelo do servidor importa tanto quanto a linguagem.
Em arquiteturas blocking thread-per-connection, o servidor atribui uma thread do SO por conexão ativa. Cada thread carrega algo em torno de 1–2 MB de overhead só para existir — stack, estruturas do kernel, bookkeeping do escalonador. Com 100k conexões:
100.000 conexões × 1 MB por thread = ~100 GB de RAM
Esse modelo escala mal não por causa da linguagem, mas por causa do modelo de I/O bloqueante. Java moderno foi bem além disso: Netty, Vert.x, Spring WebFlux e Project Loom usam event loops, epoll/kqueue e sockets não-bloqueantes — os mesmos fundamentos do Node.js. Um servidor Java bem configurado com Netty consegue lidar com 100k conexões com a mesma eficiência. O problema nunca foi Java; foi o I/O bloqueante.
Node.js executa um event loop single-threaded com I/O não-bloqueante por padrão e por design. Não há modelo alternativo a configurar — o comportamento não-bloqueante é o único comportamento. Isso o torna uma escolha natural para servidores WebSocket onde a maioria das conexões fica ociosa na maior parte do tempo, sem risco de acidentalmente usar uma API bloqueante.
Uma conexão em repouso não é só um objeto JavaScript. Seu custo completo inclui:
- o objeto
WebSockete o estado da sua aplicação (alguns KB no heap JS) - estado TCP no kernel: buffers de socket, filas de envio/recebimento, timers
- estado TLS se a conexão for criptografada: contexto de cifra, chaves de sessão
- write queues que se acumulam se o cliente for um slow consumer
Na prática, 100k conexões WebSocket ociosas no Node.js ficam em algum lugar de 2–4 GB de RAM — mas isso varia significativamente dependendo de TLS, compressão, frequência de heartbeat, tamanho do payload, se você usa ws ou Socket.IO, tuning do kernel Linux e pressão do GC. Conexões com compressão habilitada ou um adapter Redis consomem mais. Um runtime mais enxuto como uWebSockets.js pode consumir consideravelmente menos. Trate o intervalo como uma baseline aproximada, não uma garantia.
O que é WebSocket de fato
HTTP é stateless e unidirecional: o cliente envia uma requisição, o servidor responde e a conexão se encerra. Funcionalidades em tempo real como chat, dashboards ao vivo e edição colaborativa precisam do oposto — um canal persistente e bidirecional.
WebSocket resolve isso. Começa como uma requisição HTTP comum com um header Upgrade. Se o servidor aceitar, a conexão é promovida para um canal TCP full-duplex que permanece aberto até que um dos lados o encerre:
Cliente → Servidor: GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Servidor → Cliente: HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Após o handshake, ambos os lados podem enviar frames a qualquer momento sem overhead de request-response.
ws vs Socket.IO
Duas bibliotecas dominam o ecossistema WebSocket no Node.js. Elas não são equivalentes.
ws
- Implementa o protocolo WebSocket puro com abstração mínima
- Menor overhead por mensagem — sem framing customizado sobre o protocolo
- Sem fallback para long-polling
- Reconexão, rooms e semântica de eventos avançada ficam por sua conta
Socket.IO
- Adiciona um protocolo de eventos customizado sobre WebSocket (via Engine.IO por baixo)
- Reconexão automática no cliente com backoff exponencial pronta para uso
- Rooms e namespaces nativos para agrupar conexões
- Middleware de conexão (
io.use()) para autenticação antes do handshake completar - Overhead maior de framing por mensagem
Em 2025+, se você não precisa de fallback para browsers legados, ws costuma ser a escolha natural para throughput e custo por conexão. Reconexão automática e rooms do Socket.IO são valiosos, mas vêm com um custo de protocolo que se acumula sob carga.
Construindo o servidor ws puro
Passo 1 — Setup do projeto
Crie o projeto e instale as dependências:
mkdir ws-server && cd ws-server
npm init -y
npm install ws ioredis dotenv
npm install -D typescript tsx @types/node @types/ws
Crie o tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}
Adicione ao package.json:
{
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
}
}
Crie o arquivo .env:
PORT=3000
REDIS_URL=redis://localhost:6379
INSTANCE_ID=ws-1
Passo 2 — Config a partir do ambiente
Crie src/config.ts. Centralizar a leitura de variáveis de ambiente facilita rodar instâncias diferentes sem tocar no código:
import "dotenv/config";
export const config = {
port: Number(process.env.PORT ?? 3000),
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",
instanceId: process.env.INSTANCE_ID ?? "ws-1",
pubsubChannel: "ws:messages",
heartbeatMs: 30_000, // enviar ping a cada 30s
pongTimeoutMs: 10_000, // terminar se nenhum pong chegar em 10s
rateWindowMs: 1_000, // janela de rate limit: 1 segundo
maxMessagesPerWindow: 50, // máximo de 50 mensagens por segundo por conexão
maxBufferedBytes: 1_000_000, // ~1 MB de limite de buffer de saída
};
Passo 3 — Rate limiter
Por que você precisa de um rate limiter?
Uma conexão WebSocket é persistente. Diferente do HTTP, onde um cliente mal-comportado paga o custo de um novo handshake TCP a cada requisição, um cliente WebSocket conecta uma vez e pode enviar mensagens em loop fechado na velocidade máxima — milhares por segundo — sem nenhum atrito.
Sem um limite, um único cliente com bug (loop infinito no front-end, storm de reconexão, script mal configurado) pode monopolizar todo o pipeline de processamento de mensagens do servidor. Cada ciclo de CPU gasto parseando e roteando as mensagens desse cliente é um ciclo roubado de todas as outras conexões. No pior caso, o event loop acumula trabalho e a latência sobe para todos no servidor.
Um rate limiter por conexão garante fairness: cada cliente recebe o mesmo orçamento, e nenhum cliente individual afeta os outros independente do que faça. O limite é aplicado no nível da conexão, que é exatamente onde o problema ocorre.
Em produção você provavelmente usaria uma biblioteca dedicada como rate-limiter-flexible, que suporta limiters baseados em Redis — sobrevivem a restarts e funcionam entre instâncias. Neste artigo construímos um limiter simples de janela fixa em memória — suficiente para entender o conceito e manter o exemplo autocontido. Um limiter distribuído só seria necessário se você quisesse limitar um único userId somando todas as suas conexões abertas simultaneamente — um problema diferente, não coberto aqui.
Crie src/domain/rate-limiter.ts. Cada conexão terá sua própria instância independente, então um cliente não afeta os outros:
export class FixedWindowRateLimiter {
private count = 0;
private windowStart = Date.now();
constructor(
private readonly windowMs: number,
private readonly maxEvents: number,
) {}
allow(): boolean {
const now = Date.now();
if (now - this.windowStart >= this.windowMs) {
this.count = 0;
this.windowStart = now;
}
this.count++;
return this.count <= this.maxEvents;
}
}
Passo 4 — Registry de conexões
Num setup mais complexo você poderia armazenar um mapeamento de userId → instanceId no Redis para que qualquer instância soubesse exatamente para onde rotear sem precisar fazer broadcast para todas. Aqui mantemos simples: cada instância mantém um mapa em memória dos sockets que possui e depende do Redis pub/sub para broadcast, deixando a instância correta se identificar e entregar.
Crie src/infra/connection-registry.ts. O registry mapeia cada userId ao seu socket ativo. É necessário porque o Redis pub/sub entrega mensagens para todas as instâncias — cada instância verifica seu próprio registry para saber se possui o socket de destino.
export class ConnectionRegistry<T> {
private readonly users = new Map<string, Set<T>>();
add(userId: string, socket: T): void {
let sockets = this.users.get(userId);
if (!sockets) {
sockets = new Set();
this.users.set(userId, sockets);
}
sockets.add(socket);
}
getAll(userId: string): Set<T> | undefined {
return this.users.get(userId);
}
remove(userId: string, socket: T): void {
const sockets = this.users.get(userId);
if (!sockets) return;
sockets.delete(socket);
if (sockets.size === 0) {
this.users.delete(userId);
}
}
}
Cada userId mapeia para um Set de sockets em vez de um único socket. Um usuário pode ter múltiplas conexões ativas simultaneamente — dispositivos diferentes, abas do browser, ou reconexões que ainda não fecharam completamente. Quando o último socket de um usuário desconecta, o Set é removido do mapa.
Passo 5 — Tipos
Crie src/domain/types.ts:
// mensagem enviada do cliente para o servidor
export type DirectMessageInput = {
type: "message:direct";
toUserId: string;
text: string;
};
// mensagem publicada no Redis entre instâncias
export type PubSubDirectMessage = {
fromUserId: string;
toUserId: string;
text: string;
ts: number;
};
// mensagem entregue ao cliente destinatário
export type OutboundDirectMessage = {
type: "message:direct";
fromUserId: string;
text: string;
deliveredBy: string;
ts: number;
};
export type ErrorPayload = {
type: "error:rate_limit" | "error:bad_request";
message: string;
};
Passo 6 — O servidor
Crie src/server.ts. Leia seção por seção:
import "dotenv/config";
import { createServer } from "node:http";
import { Redis } from "ioredis";
import { WebSocketServer, type RawData, type WebSocket } from "ws";
import { config } from "./config.js";
import { FixedWindowRateLimiter } from "./domain/rate-limiter.js";
import type {
DirectMessageInput,
ErrorPayload,
OutboundDirectMessage,
PubSubDirectMessage,
} from "./domain/types.js";
import { ConnectionRegistry } from "./infra/connection-registry.js";
// Estender o tipo WebSocket com os campos da aplicação
type AppSocket = WebSocket & {
userId?: string;
awaitingPong: boolean;
bucket: number;
rateLimiter: FixedWindowRateLimiter;
};
Criando o servidor HTTP e WebSocket:
const server = createServer();
const wss = new WebSocketServer({
server,
perMessageDeflate: {
threshold: 1024, // comprimir apenas mensagens maiores que 1 KB
zlibDeflateOptions: { level: 6 }, // nível de compressão (1=mais rápido, 9=melhor ratio)
},
});
Conexões Redis e registry:
Por que Redis?
Todo processo Node.js é uma ilha. Sua memória — incluindo o mapa de sockets conectados — é completamente privada. Quando você roda duas instâncias atrás de um load balancer, a instância 1 não tem nenhuma informação sobre quem está conectado na instância 2.
Sem um canal compartilhado, se Alice está na instância 1 e envia uma mensagem para Bob na instância 2, a instância 1 simplesmente não consegue alcançar o socket de Bob. A mensagem é perdida em silêncio.
Você poderia contornar isso com sticky sessions — sempre roteando Alice e Bob para a mesma instância. Mas sticky sessions criam hotspots (uma instância fica com todos os usuários pesados), complicam deployments e quebram durante eventos de escala. É um remendo, não uma solução.
Redis Pub/Sub é a solução padrão. Cada instância se inscreve num canal compartilhado. Quando qualquer instância recebe uma mensagem, ela publica no Redis. O Redis faz broadcast para todos os subscribers. Cada instância verifica se o usuário destino está conectado localmente — a que encontra o socket entrega a mensagem. As outras ignoram.
Por que Redis especificamente? É in-memory (latência de microssegundos), tem semântica de Pub/Sub nativa, e já está presente na grande maioria dos stacks de produção. Se você rodar apenas uma instância e não tiver planos de escalar horizontalmente, não precisa do Redis.
São necessárias duas conexões Redis separadas. Uma conexão em modo subscribe não pode emitir outros comandos, então publicação e assinatura usam conexões diferentes:
const pub = new Redis(config.redisUrl);
const sub = new Redis(config.redisUrl);
const registry = new ConnectionRegistry<AppSocket>();
// timing wheel: 30 buckets, um processado por segundo
const BUCKETS = Math.max(1, Math.ceil(config.heartbeatMs / 1_000));
const buckets: Set<AppSocket>[] = Array.from({ length: BUCKETS }, () => new Set());
let tick = 0;
Envio seguro com proteção de backpressure de saída:
Toda mensagem de saída passa por esta função. Ela verifica bufferedAmount — os bytes enfileirados para este socket que ainda não foram enviados. Se o cliente for lento demais para drenar o buffer, termina a conexão antes que o acúmulo consuma RAM demais do servidor:
function sendJson<T extends object>(socket: WebSocket, payload: T): void {
if (socket.bufferedAmount > config.maxBufferedBytes) {
socket.terminate();
return;
}
if (socket.readyState === socket.OPEN) {
socket.send(JSON.stringify(payload));
}
}
function sendError(socket: WebSocket, payload: ErrorPayload): void {
sendJson(socket, payload);
}
Parse e validação de mensagens recebidas:
function parseClientMessage(raw: RawData): DirectMessageInput | null {
try {
const data = JSON.parse(raw.toString()) as Partial<DirectMessageInput>;
if (data.type === "message:direct" && !!data.toUserId && !!data.text) {
return data as DirectMessageInput;
}
return null;
} catch {
return null;
}
}
Terminar uma conexão stale de forma limpa:
function terminateStale(socket: AppSocket): void {
buckets[socket.bucket].delete(socket);
if (socket.userId) registry.remove(socket.userId, socket);
socket.terminate();
}
Subscriber Redis — recebendo mensagens cross-instance:
Dispara em todas as instâncias para cada mensagem publicada. Cada instância verifica seu próprio registry. Somente a que possui o socket de destino entrega a mensagem:
sub.subscribe(config.pubsubChannel, (err) => {
if (err) console.error("Falha ao subscrever:", err.message);
});
sub.on("message", (_channel, raw) => {
let event: PubSubDirectMessage;
try {
event = JSON.parse(raw) as PubSubDirectMessage;
} catch {
return;
}
const targets = registry.getAll(event.toUserId);
if (!targets) return;
const outbound: OutboundDirectMessage = {
type: "message:direct",
fromUserId: event.fromUserId,
text: event.text,
deliveredBy: config.instanceId,
ts: event.ts,
};
for (const target of targets) {
sendJson(target, outbound); // entregar para todas as conexões ativas do usuário
}
});
Handler de conexão:
wss.on("connection", (socket: AppSocket, req) => {
// 1. validar userId da query string da URL
const url = new URL(req.url ?? "/", "http://localhost");
const userId = url.searchParams.get("userId");
if (!userId) {
sendError(socket, { type: "error:bad_request", message: "Passe ?userId= no handshake" });
socket.close(1008, "userId is required");
return;
}
// 2. inicializar estado por conexão
socket.userId = userId;
socket.awaitingPong = false;
socket.bucket = Math.floor(Math.random() * BUCKETS);
socket.rateLimiter = new FixedWindowRateLimiter(
config.rateWindowMs,
config.maxMessagesPerWindow,
);
registry.add(userId, socket);
buckets[socket.bucket].add(socket);
// 3. enviar confirmação de conexão
sendJson(socket, { type: "connected", instanceId: config.instanceId, userId });
// 4. handler de pong — reseta o estado do heartbeat quando o cliente responde
socket.on("pong", () => {
socket.awaitingPong = false;
});
// 5. handler de mensagem — rate limit e depois publicar no Redis
socket.on("message", async (raw) => {
if (!socket.rateLimiter.allow()) {
sendError(socket, {
type: "error:rate_limit",
message: `Rate limit exceeded: max ${config.maxMessagesPerWindow} msgs/s per connection`,
});
return;
}
const msg = parseClientMessage(raw);
if (!msg) {
sendError(socket, { type: "error:bad_request", message: 'Envie { type: "message:direct", toUserId, text }' });
return;
}
// publicar no Redis — qualquer instância que possua o socket de destino entregará
await pub.publish(
config.pubsubChannel,
JSON.stringify({
fromUserId: userId,
toUserId: msg.toUserId,
text: msg.text,
ts: Date.now(),
} satisfies PubSubDirectMessage),
);
});
// 6. limpeza no disconnect
socket.on("close", () => {
buckets[socket.bucket].delete(socket);
registry.remove(userId, socket);
});
socket.on("error", () => {
buckets[socket.bucket].delete(socket);
registry.remove(userId, socket);
});
});
Heartbeat — timing wheel:
Uma abordagem ingênua pingaria todos os sockets conectados de uma vez a cada 30s. Com 100k conexões, isso significa 100k pings simultâneos a cada 30 segundos — um thundering herd que gera pico de CPU e rede em intervalos previsíveis.
O timing wheel resolve isso dividindo as conexões em 30 buckets. Um bucket é processado por segundo, então os pings são distribuídos uniformemente ao longo da janela de 30s. O servidor faz O(n/30) de trabalho por tick em vez de O(n) a cada 30s, e ainda há apenas um único timer ativo:
const interval = setInterval(() => {
tick = (tick + 1) % BUCKETS;
const bucket = buckets[tick];
for (const socket of bucket) {
if (socket.awaitingPong || socket.readyState !== socket.OPEN) {
terminateStale(socket);
continue;
}
socket.awaitingPong = true;
socket.ping();
}
}, 1_000); // tick a cada 1s — cada bucket é visitado uma vez a cada 30s
wss.on("close", () => clearInterval(interval));
Cada conexão é atribuída a um bucket aleatório (Math.floor(Math.random() * BUCKETS)), distribuindo os pings de forma uniforme independente do momento de chegada. Usar tick no lugar agruparia conexões que chegam ao mesmo tempo no mesmo bucket — a atribuição aleatória evita isso.
BUCKETS é derivado da config como Math.max(1, Math.ceil(config.heartbeatMs / 1_000)). Math.ceil em vez de Math.round evita arredondar para zero em valores sub-segundo, e Math.max(1, ...) garante pelo menos um bucket mesmo se heartbeatMs estiver mal configurado.
Iniciar o servidor:
server.listen(config.port, () => {
console.log(`[${config.instanceId}] native ws escutando na porta ${config.port}`);
});
Rode o servidor:
npm run dev
Teste com qualquer cliente WebSocket. Conecte em ws://localhost:3000?userId=alice e envie:
{ "type": "message:direct", "toUserId": "bob", "text": "oi" }
Construindo o servidor Socket.IO
Passo 1 — Setup do projeto
mkdir socket-io-server && cd socket-io-server
npm init -y
npm install socket.io @socket.io/redis-adapter ioredis dotenv
npm install -D typescript tsx @types/node
Use os mesmos scripts do package.json. O .env tem a mesma estrutura, basta mudar o INSTANCE_ID.
Crie tsconfig.json — idêntico ao do ws, mas com "types": ["node"] para que setInterval resolva para NodeJS.Timeout e .unref() funcione sem cast:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"types": ["node"],
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}
Passo 2 — Config
Crie src/config.ts:
import "dotenv/config";
export const config = {
port: Number(process.env.PORT ?? 3000),
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",
instanceId: process.env.INSTANCE_ID ?? "sio-1",
heartbeatMs: 30_000,
pongTimeoutMs: 10_000,
rateWindowMs: 1_000,
maxMessagesPerWindow: 50,
maxBufferedBytes: 1_000_000,
};
Passo 3 — Helper do Redis adapter
Crie src/infra/redis-adapter.ts. Isola o setup do adapter para que o arquivo principal do servidor fique limpo:
import { createAdapter } from "@socket.io/redis-adapter";
import { Redis } from "ioredis";
import type { Server } from "socket.io";
export function attachRedisAdapter(io: Server, redisUrl: string): void {
const pubClient = new Redis(redisUrl);
const subClient = pubClient.duplicate(); // conexão separada para subscribe
pubClient.on("error", (err) => console.error("[redis pub]", err));
subClient.on("error", (err) => console.error("[redis sub]", err));
io.adapter(createAdapter(pubClient, subClient));
}
Passo 4 — Tipos e rate limiter
Crie o mesmo FixedWindowRateLimiter do exemplo ws. Os tipos do Socket.IO são ligeiramente mais simples porque a biblioteca cuida do framing das mensagens:
// src/domain/types.ts
// recebido do cliente (sem campo "type" — Socket.IO usa nomes de eventos)
export type DirectMessageInput = {
toUserId: string;
text: string;
};
// entregue ao destinatário
export type ServerDirectMessage = {
fromUserId: string;
text: string;
deliveredBy: string;
ts: number;
};
export type ServerErrorPayload = {
message: string;
};
Passo 5 — O servidor
Crie src/server.ts. O arquivo completo, na ordem em que aparece:
Tipos e imports:
O Socket.IO permite tipar todos os eventos de ponta a ponta. Os generics no Server garantem que você só emite eventos que o cliente espera, com os formatos certos de payload:
import "dotenv/config";
import { createServer } from "node:http";
import { Server, type Socket } from "socket.io";
import { config } from "./config.js";
import { FixedWindowRateLimiter } from "./domain/rate-limiter.js";
import type { DirectMessageInput, ServerDirectMessage, ServerErrorPayload } from "./domain/types.js";
import { attachRedisAdapter } from "./infra/redis-adapter.js";
type ClientToServerEvents = {
"message:direct": (payload: DirectMessageInput) => void;
};
type ServerToClientEvents = {
connected: (payload: { instanceId: string; socketId: string; userId: string }) => void;
"message:direct": (payload: ServerDirectMessage) => void;
"error:rate_limit": (payload: ServerErrorPayload) => void;
"error:bad_request": (payload: ServerErrorPayload) => void;
};
type SocketData = {
userId: string;
rateLimiter: FixedWindowRateLimiter;
bpBucket: number;
};
type AppSocket = Socket<ClientToServerEvents, ServerToClientEvents, Record<string, never>, SocketData>;
Helpers de backpressure:
getBufferedAmount acessa o bufferedAmount do socket TCP subjacente via optional chaining — retorna 0 se qualquer nível da cadeia for nulo. disconnectIfSlow encapsula a decisão: desconecta e retorna true se o buffer estourou, não faz nada e retorna false caso contrário:
function getBufferedAmount(socket: unknown): number {
return (socket as { conn?: { transport?: { socket?: { bufferedAmount?: number } } } })
.conn?.transport?.socket?.bufferedAmount ?? 0;
}
function disconnectIfSlow(socket: AppSocket): boolean {
if (getBufferedAmount(socket) <= config.maxBufferedBytes) return false;
socket.disconnect(true);
return true;
}
Instanciação do servidor:
O Socket.IO por padrão inicia conexões com long-polling e faz upgrade para WebSocket depois. Isso existe por compatibilidade com proxies antigos, firewalls corporativos e browsers legados. Em aplicações modernas esse fallback cria mais problemas do que resolve:
bufferedAmountnão existe em transportes polling — o monitor de backpressure vira no-op silenciosamente para clientes ainda em polling- Polling tem overhead maior: cada poll é um ciclo completo de request-response HTTP
- O transporte pode ser inspecionado via
socket.conn.transport.name("polling"ou"websocket")
transports: ["websocket"] força WebSocket direto, tornando o comportamento previsível e o backpressure confiável:
const httpServer = createServer();
const io = new Server<ClientToServerEvents, ServerToClientEvents, Record<string, never>, SocketData>(
httpServer,
{
cors: { origin: "*" },
transports: ["websocket"],
pingInterval: config.heartbeatMs,
pingTimeout: config.pongTimeoutMs,
perMessageDeflate: {
threshold: 1024,
zlibDeflateOptions: { level: 6 },
},
},
);
Redis adapter, buckets, middleware e handler de conexão:
// uma linha — a partir daqui io.to(room).emit() funciona cross-instance
attachRedisAdapter(io, config.redisUrl);
// timing wheel: 30 buckets, um checado por segundo → O(n/30) por tick
const BP_BUCKETS = 30;
const bpBuckets: Set<AppSocket>[] = Array.from({ length: BP_BUCKETS }, () => new Set());
let bpTick = 0;
// roda antes do "connection" — next(new Error()) rejeita o handshake
io.use((socket, next) => {
const rawUserId = socket.handshake.auth.userId ?? socket.handshake.query.userId;
if (typeof rawUserId !== "string" || rawUserId.trim().length === 0) {
return next(new Error("userId é obrigatório em auth.userId ou query ?userId="));
}
socket.data.userId = rawUserId;
socket.data.rateLimiter = new FixedWindowRateLimiter(
config.rateWindowMs,
config.maxMessagesPerWindow,
);
next();
});
io.on("connection", (socket) => {
const userId = socket.data.userId;
// entrar na room do usuário — io.to("user:alice").emit() alcança Alice
// em qualquer instância, porque o adapter replica para todas via Redis
socket.join(`user:${userId}`);
// atribuir a um bucket aleatório para distribuir as checagens ao longo do tempo
const bucket = Math.floor(Math.random() * BP_BUCKETS);
socket.data.bpBucket = bucket;
bpBuckets[bucket].add(socket);
socket.emit("connected", {
instanceId: config.instanceId,
socketId: socket.id,
userId,
});
socket.on("message:direct", (payload) => {
if (!socket.data.rateLimiter.allow()) {
socket.emit("error:rate_limit", {
message: `Rate limit exceeded: max ${config.maxMessagesPerWindow} msgs/s per connection`,
});
return;
}
if (!payload?.toUserId || !payload?.text) {
socket.emit("error:bad_request", { message: "Envie { toUserId, text }" });
return;
}
// entrega para todas as conexões do usuário destino, em qualquer instância
io.to(`user:${payload.toUserId}`).emit("message:direct", {
fromUserId: userId,
text: payload.text,
deliveredBy: config.instanceId,
ts: Date.now(),
});
});
socket.on("disconnect", () => {
bpBuckets[socket.data.bpBucket].delete(socket);
});
});
Monitor de backpressure e inicialização:
O Socket.IO não expõe um ponto único de interceptação de envio, então o backpressure é verificado num intervalo separado. Aplicamos o mesmo timing wheel do servidor ws: 30 buckets, 1 bucket checado por segundo, O(n/30) por tick em vez de O(n) a cada 5s. A latência de detecção máxima sobe para 30s — aceitável para backpressure, porque um consumer lento que acumulou 1 MB de buffer e não drenou em 30s não vai se recuperar. .unref() garante que o timer não mantém o processo vivo se todas as conexões forem encerradas:
const outboundMonitor = setInterval(() => {
bpTick = (bpTick + 1) % BP_BUCKETS;
for (const socket of bpBuckets[bpTick]) {
disconnectIfSlow(socket);
}
}, 1_000);
outboundMonitor.unref();
io.on("close", () => clearInterval(outboundMonitor));
httpServer.listen(config.port, () => {
console.log(`[${config.instanceId}] socket.io escutando na porta ${config.port}`);
});
Alternativa: check-on-send com registry próprio
É possível replicar o comportamento do servidor
wsdentro do Socket.IO: manter umMap<userId, Set<AppSocket>>local, iterar os sockets do destinatário e checardisconnectIfSlowantes de cadasocket.emit(). O problema é queio.to(room).emit()com o Redis adapter também entrega para sockets locais — você teria double delivery. Para evitar isso, precisaria abandonar o adapter para entrega de mensagens e fazer pub/sub manual comioredis, exatamente como o servidorwsfaz. Nesse ponto, o Socket.IO passa a ser apenas a camada de conexão (handshake, middleware, reconexão), e você assumiu toda a lógica de roteamento. Se check-on-send for um requisito hard — mensagens financeiras, gaming com latência crítica — essa arquitetura faz sentido, e owspuro provavelmente é a escolha mais honesta. Para mensageria geral, o timing wheel é suficiente: um consumer que acumulou 1 MB sem drenar em 30s não vai se recuperar — desconectar nesse ponto é equivalente a desconectar na hora.
Rodando múltiplas instâncias com Docker Compose
Ambos os servidores são stateless — o estado vive no Redis. Você pode rodar quantas instâncias quiser atrás de um load balancer. O docker-compose.yml abaixo é apenas um exemplo para desenvolvimento local — sobe o Redis mais duas instâncias de cada servidor para demonstrar a entrega cross-instance. Não use este arquivo em produção diretamente; um ambiente real exigiria orquestração adequada (Kubernetes, ECS, etc.), variáveis de ambiente seguras e uma estratégia de load balancing.
services:
redis:
image: redis:7-alpine
container_name: ws-redis
ports:
- "6379:6379"
socketio-1:
image: node:20-alpine
container_name: socketio-1
working_dir: /app
command: sh -c "npm install && npm run dev"
volumes:
- <CAMINHO_DO_PROJETO>:/app
- /app/node_modules
environment:
- PORT=3001
- REDIS_URL=redis://redis:6379
- INSTANCE_ID=socketio-1
depends_on:
- redis
ports:
- "3001:3001"
socketio-2:
image: node:20-alpine
container_name: socketio-2
working_dir: /app
command: sh -c "npm install && npm run dev"
volumes:
- <CAMINHO_DO_PROJETO>:/app
- /app/node_modules
environment:
- PORT=3002
- REDIS_URL=redis://redis:6379
- INSTANCE_ID=socketio-2
depends_on:
- redis
ports:
- "3002:3002"
ws-1:
image: node:20-alpine
container_name: ws-1
working_dir: /app
command: sh -c "npm install && npm run dev"
volumes:
- <CAMINHO_DO_PROJETO>:/app
- /app/node_modules
environment:
- PORT=4001
- REDIS_URL=redis://redis:6379
- INSTANCE_ID=ws-1
depends_on:
- redis
ports:
- "4001:4001"
ws-2:
image: node:20-alpine
container_name: ws-2
working_dir: /app
command: sh -c "npm install && npm run dev"
volumes:
- <CAMINHO_DO_PROJETO>:/app
- /app/node_modules
environment:
- PORT=4002
- REDIS_URL=redis://redis:6379
- INSTANCE_ID=ws-2
depends_on:
- redis
ports:
- "4002:4002"
Suba tudo:
docker compose up
Para verificar a entrega cross-instance: conecte Alice ao ws-1 (localhost:4001?userId=alice) e Bob ao ws-2 (localhost:4002?userId=bob). Envie uma mensagem de Alice para Bob. O campo deliveredBy na mensagem recebida por Bob mostrará ws-2, confirmando que a mensagem passou pelo Redis de uma instância para a outra.
Backpressure e rate limiting — os dois lados da proteção por conexão
Dois problemas distintos podem sobrecarregar seu servidor na camada de conexão. Exigem soluções separadas.
Entrada (ingress) — o cliente envia rápido demais
Um cliente bugado ou malicioso pode inundar o servidor com milhares de mensagens por segundo. O FixedWindowRateLimiter construído acima limita cada conexão de forma independente — um cliente ruim não priva os outros.
Saída (egress) — o cliente recebe lento demais
Imagine um cliente numa rede degradada. O servidor continua enviando; o cliente não consegue drenar rápido o suficiente. O buffer de envio TCP do kernel enche. O Node.js enfileira dados em memória. Se não for controlado, um único cliente lento pode consumir gigabytes de RAM do servidor enquanto todas as outras conexões permanecem saudáveis.
bufferedAmount é a métrica a observar: bytes enfileirados para este socket que ainda não foram transmitidos.
Abordagem ws nativo — verificado sincronamente antes de cada envio, O(1), sem latência de detecção:
function sendJson(socket, payload) {
if (socket.bufferedAmount > MAX_BUFFER_BYTES) {
socket.terminate(); // recuperar a memória enfileirada imediatamente
return;
}
socket.send(JSON.stringify(payload));
}
Abordagem Socket.IO — mesmo timing wheel do ws: 30 buckets, 1 bucket por segundo, O(n/30) por tick. Latência de detecção máxima de 30s — aceitável para backpressure. Coberta no passo 5 acima.
Resumo: rate limit protege contra clientes que enviam demais; monitoramento de bufferedAmount protege contra clientes que recebem de menos. Ambas são necessárias — guardam lados diferentes da conexão.
Conclusão
Socket.IO dá produtividade e convenções prontas: reconexão automática, rooms, namespaces e middleware de conexão prontos para uso sem código extra. O tradeoff é maior overhead de protocolo e menos controle sobre comportamento de baixo nível.
ws dá menor overhead, maior teto de performance e modelo mental mais simples. O tradeoff é que cada funcionalidade que você ganha de graça no Socket.IO vira sua responsabilidade.
A melhor decisão é a que combina com o custo operacional que seu time quer carregar.
Referência dos exemplos no repositório
Repositório: github.com/LuizFernando991/websocket-scaling
Socket.IO:examples/socket-io/src/server.tswspuro:examples/ws-raw/src/server.ts- Docker Compose:
docker-compose.yml
