O que o Socket.IO adiciona aos WebSockets
O Socket.IO é uma biblioteca para comunicação em tempo real baseada em eventos entre um servidor e seus clientes. Internamente, ele utiliza o protocolo WebSocket sempre que possível e recorre ao HTTP long polling quando não consegue. Sobre esse transporte, ele oferece um pequeno protocolo de aplicação: eventos nomeados, rooms, acknowledgements, reconexão automática e heartbeats.
Essa camada extra é justamente o objetivo. Uma conexão WebSocket pura fornece apenas um canal de comunicação e nada mais. Toda aplicação real acaba tendo que reconstruir as mesmas funcionalidades: como agrupar conexões por sala de chat ou tenant, como saber se uma mensagem foi recebida, como reconectar de forma limpa, como fazer broadcast entre vários servidores? O Socket.IO responde a essas perguntas de uma vez, em uma biblioteca bem testada, para que você possa focar seu tempo no produto.
A contrapartida é que o Socket.IO não é um WebSocket puro. Ele define seu próprio handshake e formato de pacote sobre o Engine.IO, portanto, um cliente WebSocket nativo não consegue se conectar a um servidor Socket.IO. Ambos os lados devem utilizar o cliente Socket.IO. Se você precisar de interoperabilidade com clientes WebSocket arbitrários, utilize o guia de WebSockets e a biblioteca ws.
O restante deste guia assume que você já conhece o protocolo puro e agora deseja a versão “batteries-included”.
Configuração do servidor e do cliente
O servidor se anexa a um servidor HTTP existente, que geralmente é o mesmo que serve a sua API. Isso significa apenas uma porta, um único certificado TLS e nenhuma infraestrutura extra.
import { Server } from "socket.io";
import { createServer } from "node:http";
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: { origin: process.env.APP_ORIGIN, credentials: true },
});
io.on("connection", (socket) => {
console.log("connected", socket.id);
});
httpServer.listen(3000);
O cliente se conecta com a mesma origin e se autentica através do handshake.
import { io } from "socket.io-client";
const socket = io("https://api.example.com", {
auth: { token: getAccessToken() },
withCredentials: true,
});
socket.on("connect", () => console.log("connected", socket.id));
socket.on("disconnect", (reason) => console.log("closed", reason));
O socket.id é um identificador por conexão. Ele é útil para logs e para endereçar uma conexão específica, mas muda ao reconectar, portanto, nunca o utilize como um ID de usuário ou o armazene como um estado persistente.
Eventos, confirmações e callbacks
Tudo no Socket.IO é um evento nomeado que carrega um payload JSON. Tanto o servidor quanto o cliente chamam emit para enviar e on para ouvir. Os nomes são apenas strings, então escolha uma convenção e mantenha-a — noun:verb como message:send, message:new e presence:joined funciona bem para ambos os lados.
O recurso que diferencia o Socket.IO de um socket puro é a confirmação (acknowledgement). Se o emissor passar um callback como último argumento, o receptor pode chamá-lo para responder, transformando o emit em uma requisição/resposta sobre a mesma conexão.
// client
socket.emit("message:send", { room: "general", body: "hello" }, (ack) => {
if (!ack.ok) showError(ack.error);
});
// server
socket.on("message:send", (payload, ack) => {
if (!payload.body) return ack({ ok: false, error: "empty" });
io.to(payload.room).emit("message:new", payload);
ack({ ok: true, at: Date.now() });
});
Use confirmações para qualquer evento cujo resultado o cliente precise saber: criar um registro, entrar em uma sala, enviar um formulário. Para broadcasts puros onde ninguém está esperando por uma resposta, o emit simples é suficiente. Um meio-termo é o socket.timeout(5000).emit(...), que falha o callback caso nenhuma confirmação chegue a tempo.
Middleware e o pipeline de eventos
O Socket.IO possui duas camadas de middleware, e colocar a lógica na camada correta mantém os handlers limpos.
Connection middleware, registrado com io.use ou namespace.use, é executado uma vez por conexão antes do evento connection. É aqui que devem ficar a autenticação, a resolução de tenant e a configuração por conexão. O middleware é executado na ordem de registro, e cada um chama next() para continuar ou next(new Error(...)) para rejeitar.
io.use((socket, next) => {
const startedAt = Date.now();
socket.on("disconnect", () => {
metrics.observe("socket.duration", Date.now() - startedAt);
});
next();
});
Event middleware, registrado com socket.use, é executado para cada evento recebido naquele socket. É o lugar natural para validação, rate limiting e logging estruturado, pois ele visualiza o nome do evento e o payload antes de qualquer handler.
const chat = io.of("/chat");
chat.use((socket, next) => {
if (!socket.data.user) return next(new Error("unauthorized"));
next();
});
chat.use((socket, next) => {
socket.onAny((event, ...args) => {
logger.info({ event, userId: socket.data.user.id, args });
});
next();
});
socket.onAny observa cada evento recebido, e socket.onAnyOutgoing observa tudo o que você envia, o que juntos fornecem um trace completo de uma conexão sem tocar em um único handler. O middleware também é ciente de namespaces: o namespace /admin pode exigir uma role diferente sem afetar /chat.
Rooms e namespaces
Existem dois mecanismos de agrupamento que mantêm as mensagens direcionadas em vez de serem transmitidas para todos.
Uma room é um rótulo no lado do servidor aplicado a um conjunto de sockets. Qualquer socket pode entrar ou sair de qualquer room a qualquer momento, e um socket pode estar em várias rooms simultaneamente. Quando você emite um evento para uma room, apenas os seus membros recebem esse evento.
io.on("connection", (socket) => {
socket.join(`user:${socket.data.user.id}`);
socket.on("room:join", (room, ack) => {
socket.join(room);
ack({ ok: true });
});
});
Um namespace é um canal de comunicação separado sob um caminho, como /chat ou /admin. Namespaces possuem seu próprio middleware, seus próprios manipuladores de conexão e suas próprias rooms. Use namespaces para separar responsabilidades que não devem compartilhar eventos de forma alguma — como um namespace de chat público e um namespace de administração interna — em vez de usá-los para modelar dados dentro de uma única funcionalidade.
Rooms são a ferramenta principal. Modele-as com base nas coisas que interessam aos seus usuários: uma conversa, um documento, um tenant, um dashboard. Assim, o broadcast torna-se uma única linha de código em vez de um loop sobre as conexões.
Broadcasting e direcionamento
O Socket.IO possui um vocabulário conciso para definir quem recebe um evento, e utilizá-lo corretamente evita tanto vazamentos de dados quanto o desperdício de fan-out.
io.emit(...)— todos os sockets conectados. Raramente é o que você deseja.socket.emit(...)— apenas o socket que está manipulando o evento atual.socket.broadcast.emit(...)— todos, exceto o remetente.socket.to(room).emit(...)— todos na sala, exceto o remetente.io.to(room).emit(...)— todos na sala, incluindo o remetente.io.to(roomA).to(roomB).emit(...)— a união de ambas as salas.socket.to(socketId).emit(...)— um socket específico através do id.
socket.on("typing", ({ room }) => {
// Tell the room, but not the person typing.
socket.to(room).emit("typing", { user: socket.data.user.id });
});
O encadeamento de to une os destinatários; não existe um operador de interseção na API principal. Se você precisar de “membros da sala A que também são administradores”, modele isso como uma sala própria — room:${id}:admins — em vez de tentar calcular isso no momento do emit.
Autenticando o handshake
A autenticação deve ocorrer no handshake da conexão, e não em uma primeira mensagem. O middleware do Socket.IO registrado com io.use é executado antes do evento connection, permitindo que você rejeite um socket não autenticado antes que ele possa emitir qualquer evento ou entrar em alguma sala.
io.use((socket, next) => {
const token = socket.handshake.auth.token;
try {
const payload = verifyToken(token);
socket.data.user = { id: payload.sub, roles: payload.roles };
next();
} catch {
next(new Error("unauthorized"));
}
});
Dois detalhes são importantes. Primeiro, o socket.data é o local ideal para anexar o estado por conexão; ele persiste durante toda a vida da conexão e fica disponível em todos os handlers. Segundo, uma conexão rejeitada envia um evento connect_error no cliente, permitindo que a UI solicite um novo token em vez de tentar a conexão indefinidamente.
Para clientes no navegador, um cookie enviado durante o handshake é uma alternativa a um token explícito, permitindo que você reutilize a infraestrutura de sessão já existente. De qualquer forma, sempre configure a origin do cors para a sua própria aplicação e autorize cada evento com base no usuário em socket.data — a autenticação prova quem se conectou, não o que eles têm permissão para fazer.
Reconexão e recuperação de estado da conexão
Conexões caem. Laptops entram em modo de suspensão, celulares trocam de rede, load balancers são reiniciados. O Socket.IO reconecta automaticamente com exponential backoff e jitter, e emite os eventos reconnect_attempt e reconnect para que você possa exibir a UI correta.
Por padrão, porém, os eventos enviados enquanto o cliente estava offline são perdidos. O cliente reconecta como um novo socket e retoma a partir de agora. Isso é aceitável para um feed ao vivo, mas incorreto para um chat ou um documento colaborativo.
A recuperação de estado da conexão (connection state recovery) resolve o caso de interrupções curtas. Ative-a no servidor, e um cliente que reconectar apresentando seu session id receberá os pacotes que perdeu, desde que a desconexão tenha sido breve.
const io = new Server(httpServer, {
connectionStateRecovery: {
maxDisconnectionDuration: 2 * 60 * 1000,
skipMiddlewares: true,
},
});
A recuperação é deliberadamente limitada: ela mantém pacotes recentes na memória por um curto período e apenas na instância que recebeu a conexão original, portanto, não é um substituto para armazenamento persistente. Qualquer coisa que precise sobreviver a uma interrupção mais longa — mensagens, pedidos, edições de documentos — deve ficar em um banco de dados, com o socket sendo usado apenas para notificar os clientes que algo mudou.
Escalando com o adapter do Redis
Um servidor Socket.IO mantém seus sockets e salas na memória. Se você executar duas instâncias atrás de um load balancer, elas serão efetivamente duas aplicações real-time separadas: uma mensagem emitida na instância A nunca chegará aos clientes na instância B.
O Redis adapter resolve isso. Cada instância publica seus broadcasts em um canal de pub/sub do Redis e se inscreve no mesmo canal, portanto, um emit em uma instância é entregue aos sockets correspondentes em todos os lugares.
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
const io = new Server(httpServer, {
adapter: createAdapter(pubClient, subClient),
});
Duas observações operacionais. Primeiro, sticky sessions ainda são necessárias durante a fase de HTTP polling, porque o handshake do Engine.IO abrange várias requisições que devem chegar à mesma instância. Configure o load balancer para afinidade baseada em cookies ou force apenas o transporte WebSocket. Segundo, o adapter utiliza o pub/sub do Redis, que é rápido, mas não durável: uma mensagem publicada enquanto uma instância está reiniciando será perdida. O Redis também é abordado em seu próprio guia de Redis.
Para implantações muito grandes, existe um adapter sharded que distribui os canais em um cluster Redis, além de adapters para outros brokers, mas comece com o padrão.
Emitindo eventos de fora de um socket
Eventos em tempo real raramente se originam dentro de um manipulador de conexão. Uma requisição HTTP cria um comentário, um worker finaliza um relatório, um webhook confirma um pagamento — todos esses cenários precisam notificar os clientes conectados.
Mantenha uma referência ao io e emita para uma room de qualquer lugar:
app.post("/comments", async (req, res) => {
const comment = await db.comment.create({ data: req.body });
io.to(`post:${comment.postId}`).emit("comment:new", comment);
res.status(201).json(comment);
});
Rooms são a ferramenta ideal aqui porque são compartilhadas entre instâncias quando o adapter está configurado. Para alcançar um usuário específico, emita para uma room por usuário — user:${id} — que o socket entrou no momento da conexão, em vez de rastrear socket ids. Isso garante que continue funcionando quando o usuário tiver duas abas abertas, se reconectar ou cair em uma instância diferente.
Se você realmente precisar de um socket id, io.in(room).fetchSockets() retorna os sockets ativos em uma room, o que é útil para contagens de presença e envios direcionados.
Presença, indicadores de digitação e recursos colaborativos
Rooms combinados com um store compartilhado cobrem a maioria dos recursos colaborativos. A presença — quem está online — é o caso mais comum, e a versão simplista deixa de funcionar no momento em que você executa mais de uma instância.
O padrão é manter a presença autoritativa no Redis, e não em um map de JavaScript, e realizar a limpeza ao desconectar:
io.on("connection", async (socket) => {
const { id } = socket.data.user;
await redis.sadd("online", id);
socket.on("disconnect", async () => {
const sockets = await io.in(`user:${id}`).fetchSockets();
if (sockets.length === 0) await redis.srem("online", id);
});
});
A verificação fetchSockets é fundamental: um usuário com duas abas abertas não deve aparecer como offline quando uma das abas for fechada. Indicadores de digitação, posições do cursor e flags de “alguém está editando” seguem a mesma lógica — estado efêmero transmitido para uma room, com um tempo de expiração curto para que um cliente que travou não deixe um indicador obsoleto para sempre.
Para edição colaborativa real com resolução de conflitos, utilize uma biblioteca de CRDT como o Yjs e envie as atualizações via socket, em vez de tentar inventar um algoritmo de merge.
Validação, rate limiting e segurança
Um socket é um canal de entrada não confiável, exatamente como uma requisição HTTP. Trate cada payload de evento como hostil até que seja validado.
- Valide a estrutura. Faça o parse dos payloads com uma biblioteca de schema como Zod antes de manipulá-los, e rejeite com um acknowledgement em vez de lançar um erro.
- Aplique rate limit. Um socket pode emitir milhares de eventos por segundo. Use um token bucket por socket e desconecte ou limite a taxa de usuários abusivos.
- Limite o tamanho do payload.
maxHttpBufferSizedefine o limite de quanto uma única mensagem pode carregar. - Autorize cada evento. Verifique novamente se o usuário em
socket.datapode agir na sala ou recurso, e não apenas se ele está conectado. - Valide a origin. Configure
cors.origine não o deixe aberto em produção. - Mantenha timeouts. Heartbeats e idle timeouts evitam que conexões abandonadas causem vazamentos de memória.
io.on("connection", (socket) => {
socket.use(([event, payload], next) => {
const parsed = MessageSchema.safeParse(payload);
if (!parsed.success) return next(new Error("invalid_payload"));
if (!takeToken(socket.id)) return next(new Error("rate_limited"));
next();
});
});
O middleware por socket registrado com socket.use é o local ideal para centralizar essas verificações, permitindo que os handlers individuais permaneçam focados na lógica de negócio.
Debugging e observabilidade
A primeira ferramenta já vem integrada. Definir DEBUG=socket.io:* (ou engine*) imprime o handshake, upgrades de transporte e o fluxo de pacotes, o que geralmente é suficiente para diagnosticar um cliente que não consegue se conectar.
Para produção, monitore os sinais que realmente preveem incidentes:
- Sockets conectados — uma queda repentina indica um deploy, um crash ou uma partição de rede.
- Eventos por segundo, por nome — um pico em um único evento geralmente é causado por um loop infinito no cliente.
- Motivos de desconexão —
ping timeoutetransport closeapontam para problemas de rede ou proxy, enquantoio server disconnectsignifica que seu código fechou o socket. - Saúde do adapter — se o pub/sub do Redis estiver lento ou desconectado, as transmissões entre instâncias param sem que nenhum socket pareça ter falhado.
io.on("connection", (socket) => {
socket.on("disconnect", (reason) => {
metrics.increment("socket.disconnect", { reason });
});
});
O pacote @socket.io/admin-ui adiciona um dashboard sobre esses mesmos dados, e vale a pena executá-lo em staging para que você possa visualizar as salas e sockets conforme eles mudam. Como em qualquer sistema de tempo real, as falhas mais confusas são as parciais: uma instância está funcionando bem, outra não, e apenas uma visão por instância revela isso.
Testando um servidor em tempo real
Código em tempo real é testável. Inicie o servidor em uma porta efêmera, conecte algumas instâncias de socket.io-client e faça asserções nos eventos que elas recebem. A disciplina fundamental aqui é aguardar pelos eventos em vez de usar sleep, para que os testes sejam rápidos e determinísticos.
import { io as Client } from "socket.io-client";
test("broadcasts a message to the room", async () => {
const a = Client(url, { auth: { token } });
const b = Client(url, { auth: { token } });
await Promise.all([once(a, "connect"), once(b, "connect")]);
a.emit("room:join", "r1");
b.emit("room:join", "r1");
const received = once(b, "message:new");
a.emit("message:send", { room: "r1", body: "hi" });
const [message] = await received;
expect(message.body).toBe("hi");
a.close();
b.close();
});
Teste também os caminhos de falha, pois é onde residem os bugs de tempo real: um cliente não autenticado deve receber connect_error, um payload inválido deve ser rejeitado por um reconhecimento (acknowledgement) e um socket que se desconecta deve sair de suas salas. Use uma sala ou namespace exclusivo por teste para que testes paralelos não vejam os eventos uns dos outros, e sempre feche os clientes para que o processo de teste seja encerrado.
Fazendo o deploy de um servidor Socket.IO
O deploy de um Socket.IO é essencialmente um deploy HTTP com dois requisitos extras: conexões de longa duração e estado compartilhado. A maioria dos imprevistos acontece por esquecer um desses pontos.
- Uma única porta. Vincule o Socket.IO ao mesmo servidor HTTP da sua API e finalize o TLS no proxy. Não há necessidade de expor uma porta separada.
- Suporte do proxy para upgrades. O Nginx e a maioria dos load balancers precisam de configuração explícita para repassar os headers
UpgradeeConnection; sem isso, a conexão permanece silenciosamente em polling. - Sticky sessions. A afinidade baseada em cookies mantém o handshake de polling em uma única instância. Se você forçar o transporte apenas via WebSocket, a afinidade importa menos, mas o handshake ainda precisa ser concluído em algum lugar.
- Redis adapter. Configure-o antes mesmo de a segunda instância existir, e não depois que os usuários relatarem mensagens perdidas.
- Graceful shutdown. No
SIGTERM, pare de aceitar novas conexões e feche o servidor para que os eventos em andamento sejam finalizados.
process.on("SIGTERM", async () => {
io.close(); // disconnects clients and stops the server
await pubClient.quit();
await subClient.quit();
httpServer.close();
});
upstream io_nodes {
ip_hash;
server 10.0.0.1:3000;
server 10.0.0.2:3000;
}
Dimensione cada instância com base nas conexões que ela suporta, e não apenas no throughput de requisições. Cada socket consome memória e um file descriptor, e uma única instância com dezenas de milhares de conexões falhará de maneiras que um serviço baseado em requisições jamais falharia. Faça o scale out cedo, monitore a contagem de conexões e defina um período de carência (grace period) nos deploys longo o suficiente para que os clientes se reconectem a uma instância saudável.
Quando WebSockets puros são a melhor escolha
Socket.IO nem sempre é a resposta. Escolha o protocolo puro quando:
- A interoperabilidade for importante. Clientes WebSocket nativos, outras linguagens e ferramentas rigorosas de protocolo não conseguem lidar com o handshake customizado do Socket.IO.
- Você precisar do menor cliente possível. O Socket.IO requer a instalação de um bundle de cliente; já o WebSocket puro já vem integrado ao navegador.
- Sua infraestrutura for nativa para WebSocket. Alguns gateways, brokers e edge runtimes encerram conexões WebSocket, mas não suportam o fallback de polling do Socket.IO.
- Você controlar ambas as pontas e não quiser abstrações. Se salas (rooms) e reconexão forem triviais para o seu caso de uso,
wsoferece menos complexidade para gerenciar.
Por outro lado, escolha o Socket.IO quando quiser salas, confirmações de recebimento (acknowledgements), reconexão automática e broadcasting multi-instância sem precisar implementá-los do zero. Para a maioria dos times de produto, essa lista representa todo o conjunto de funcionalidades da sua camada de tempo real, e é exatamente por isso que a biblioteca existe.
Melhores práticas
- Autentique no
io.usee armazene o usuário nosocket.data, não em uma closure. - Modele as rooms com base em objetos reais do domínio — conversas, documentos, tenants.
- Use acknowledgements para eventos cujo resultado o cliente precise receber.
- Prefira
socket.to(room)quando o remetente não deva receber seu próprio evento. - Habilite a recuperação de estado da conexão, mas mantenha o estado durável em um banco de dados.
- Adicione o adaptador Redis antes de adicionar uma segunda instância e configure sticky sessions.
- Emita eventos de handlers HTTP e workers através de rooms, não por socket ids armazenados.
- Valide, autorize e aplique rate limit em cada evento.
- Limpe a presença no
disconnecte usefetchSocketspara lidar com múltiplas abas. - Defina
maxHttpBufferSize, origens CORS e timeouts explicitamente.
Erros comuns
- Chamar
io.emitquando apenas uma room deveria receber o evento. - Assumir que Socket.IO e WebSockets são intercambiáveis e, então, falhar ao conectar um cliente nativo.
- Confiar em
socket.handshake.authsem verificar o token. - Armazenar a presença em um
Maplocal e perdê-la ao utilizar um load balancer. - Esquecer das sticky sessions e quebrar o handshake de polling.
- Esperar que a reconexão reenvie eventos sem a recuperação do estado da conexão.
- Usar
socket.idcomo id de usuário e, então, ter problemas ao reconectar. - Executar tarefas pesadas ou bloqueantes dentro de um event handler, travando todos os sockets da instância.
- Deixar a validação de payload e o rate limiting para o frontend.
- Tratar o socket como armazenamento durável para qualquer dado que não possa ser perdido.
Próximos passos
O Socket.IO é a camada de alto nível sobre o protocolo abordado no guia de WebSockets, que explica frames, heartbeats e o handshake de upgrade que você está abstraindo agora. O guia de Redis aprofunda-se no pub/sub e no estado compartilhado por trás do adapter, e o de Node.js Basics explica o event loop no qual cada handler é executado. Se o seu servidor Socket.IO estiver conectado a uma API HTTP, o guia de Express cobre o roteamento e o middleware que compartilham o mesmo processo.