← Voltar para o blog
07 de maio de 2026*25 min de leitura

WebSocket em Escala com Node.js: ws vs Socket.IO

WebSocket at Scale com Node.js: guia prático comparando ws e Socket.IO na construção de servidores WebSocket escaláveis. Aprenda Redis Pub/Sub entre instâncias, backpressure com bufferedAmount, rate limiting e heartbeat com timing wheel.

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 WebSocket e 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:

  • bufferedAmount nã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 ws dentro do Socket.IO: manter um Map<userId, Set<AppSocket>> local, iterar os sockets do destinatário e checar disconnectIfSlow antes de cada socket.emit(). O problema é que io.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 com ioredis, exatamente como o servidor ws faz. 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 o ws puro 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.ts
  • ws puro: examples/ws-raw/src/server.ts
  • Docker Compose: docker-compose.yml