O difícil de WebSocket não é mandar mensagem

Rafael Coelho
· 9 min de leitura

Eu já criei e trabalhei em vários apps e jogos com atualizações em tempo real e sempre tem alguns problemas que podem acontecer quando tu faz isso usando WebSocket; e é exatamente disso que eu quero falar.

 

Porque quando a gente pensa em fazer um app em tempo real, a gente pensa na mensagem: configurar o Socket.io, conectar o cliente, mandar um emit e todo mundo recebe.

Mas aí tem uns bugs que SEMPRE acontecem com múltiplas conexões simultâneas, tipo sequência de acontecimentos, estado compartilhado, quem caiu e quando... e etc.

 

Pra explicar isso eu vou usar um app que eu construí: um Planning Poker.

Sabe aquele em que o time entra numa mesa pra votar o tamanho das tarefas? Então, um desses.

 

E foi nele que eu esbarrei num bug que resume bem o assunto.

 

E olha: nada disso é específico desse cenário...

Chat, dashboard, editor colaborativo, jogo multiplayer... os problemas são sempre os mesmos.


O bug do fantasma na mesa

 

Quando tu vai gerenciar algo assim, tem um caminho natural que é usar o socket.id que o WebSocket te dá como uma chave identificadora do usuário.

Aí tu cria um Map de jogadores onde ele entra no connection e sai no disconnect.

js
const players = new Map(); // socketId -> player io.on("connection", (socket) => { players.set(socket.id, { name, vote: null }); socket.on("disconnect", () => { players.delete(socket.id); }); });

SIMPLES ASSIM e tu tem uma mesa completamente funcional.

 

Mas não estamos falando de ciências exatas aqui, né?

Computaria é Ciência Mística Probabilística e depende de muitas coisas que não necessariamente são do teu sistema.

 

Se alguém tá num ônibus usando o seu sistema no 4G ou abriu essa mesa em 5 abas diferentes do mesmo navegador, pode dar algumas bugadas interessantes.

À cada entrada ou atualização da página, o servidor vai gerar um novo socket.id e você vai ter que dar um jeito de lidar com ele.

 

Isso não se resolve com um if a mais no connect.

É problema de como o estado tá organizado.

 

Dá pra separar em 5 problemas... e advinha? Eu fiz isso.

Segue a lista:


Problema 1: conexão não é presença

 

E o erro de base (meu e o de quase todo mundo) é tratar uma conexão como presença ativa de usuário.

Pensa comigo:

Conexão -> socket aberto.

Presença -> pessoa participando da mesa.

 

Parece a mesma coisa, mas não é.

Conexão cai e volta o tempo todo: troca de rede, aba em background, notebook fechado.

A presença é uma decisão tua: a partir de quando essa pessoa saiu de verdade?

Então olha como fica o servidor.

A presença não espelha o socket: o estado é um objeto de sessões, e cada sessão tem uma lista de participantes:

js
// sessionId -> { sessionId, sessionName, participants: [], isRevealed } const sessions = {}; // sessionId -> Set<userId> de saídas adiadas const pendingRemovals = {}; // participante = { userId, userName, socketId, selectedCard } // toda sessão nasce por aqui — e o Set de saídas adiadas nasce junto function getOrCreateSession (sessionId) { if (!sessions[sessionId]) { sessions[sessionId] = { sessionId, sessionName: "", participants: [], isRevealed: false }; pendingRemovals[sessionId] = new Set(); } return sessions[sessionId]; } // manda o estado atual da sessão pra todo mundo na sala function updateSession (sessionId) { io.to(sessionId).emit("session_update", sessions[sessionId]); }

Repara em duas coisas:

  1. O socketId fica guardado só como referência da conexão atual; a chave de verdade é o userId.
  2. Tem um segundo mapa pendingRemovals com as saídas adiadas de cada sessão. Ele nasce junto com a sessão (pra nunca ser undefined na hora de usar) e é ele que resolve o fantasma (já chego lá).

Problema 2: reconexão — o socket.id muda

Guarda essa: nunca use o id da conexão como identidade do usuário.

O socket.id muda a cada reconexão e isso não é bug, é assim que a plataforma funciona.

 

A solução é a identidade vir de fora.

No primeiro acesso, o cliente gera um userId com uuidv4() e guarda no localStorage ou em algum cookie. Daí em diante, toda conexão manda esse userId no join_room e o servidor procura por ele antes de adicionar.

Se liga:

js
function addParticipant (sessionId, userId, userName, socketId) { const session = getOrCreateSession(sessionId); const existing = session.participants.find((p) => p.userId === userId); if (existing) { // reconectou: só reamarra o socket novo existing.socketId = socketId; } else { session.participants.push({ userId, userName, socketId, selectedCard: null }); } }

Viu o find? É ele que evita a duplicata.

Reconectou com socket.id novo e o mesmo userId? O participante já existe, então só atualiza o socketId. A pessoa volta pro lugar dela como se nada tivesse acontecido.

E olha o último passo do diagrama: o disconnect do socket velho ainda dispara, atrasado.

Mas como o participante já foi reamarrado pro socket novo, esse disconnect procura pelo socketId antigo, não acha ninguém e não faz nada. A reconexão se resolve sozinha.

O problema de verdade é outro: quando o disconnect acha o participante e a rodada tá no meio de uma leitura.


Problema 3: ordem das mutações

Agora sim, o problema que me pegou.

Quando alguém desconecta de verdade, o servidor precisa decidir o que fazer com a saída. E a decisão depende do estado da rodada:

js
socket.on("disconnect", () => { const session = Object.values(sessions).find((s) => s.participants.some((p) => p.socketId === socket.id) ); // socket já foi reamarrado (problema 2): ignora if (!session) return; const userId = session.participants.find((p) => p.socketId === socket.id)?.userId; if (!userId) return; if (session.isRevealed) { // cartas na mesa: NÃO remove agora, adia pendingRemovals[session.sessionId].add(userId); } else { // votação aberta: remove na hora session.participants = session.participants.filter((p) => p.userId !== userId); updateSession(session.sessionId); } });

Por que adiar?

Pensa na cena: se isRevealed é true, a média e os votos tão na tela de todo mundo naquele momento.

Se eu removo o participante ali, o número muda no meio da leitura, na frente do time.

 

Então em vez de remover, o userId entra no pendingRemovals e a pessoa continua na mesa até a rodada fechar. A limpeza acontece no reset_voting:

js
socket.on("reset_voting", ({ sessionId }) => { sessions[sessionId].isRevealed = false; sessions[sessionId].participants.forEach((p) => (p.selectedCard = null)); // só agora as saídas adiadas acontecem de verdade sessions[sessionId].participants = sessions[sessionId].participants.filter( (p) => !pendingRemovals[sessionId].has(p.userId) ); pendingRemovals[sessionId].clear(); updateSession(sessionId); });

E é AQUI que o bug do fantasma acaba.

Quem cai com as cartas reveladas fica na mesa, o voto continua valendo como tava, e a média não muda.

 

A remoção só acontece quando a votação reinicia. No momento em que os votos iam zerar de qualquer jeito.

 

E a lição que fica é essa: os eventos chegam em qualquer ordem, e aplicar uma mudança na hora errada estraga o que tá na tela. Às vezes a resposta certa pra um evento não é agir na hora: é guardar e agir na hora certa.


Problema 4: conexão morta

Agora repara numa coisa: tudo até aqui assume que o disconnect chega... só que conexão morta nem sempre avisa.

 

Notebook hibernou, celular entrou no túnel e o cliente já era, mas o servidor continua achando que tem alguém do outro lado. Sem detecção, esse participante nunca sai da mesa.

 

A saída é heartbeat: o servidor manda um ping de tempos em tempos e espera o pong.

Não voltou no prazo? Conexão morta, dispara o disconnect.

 

O Socket.io já faz isso por padrão — tu só ajusta os tempos:

js
const io = new Server(httpServer, { pingInterval: 25000, // ping a cada 25s pingTimeout: 20000, // sem pong em 20s → conexão morta → dispara disconnect });

E quando esse disconnect atrasado finalmente dispara, ele cai no mesmo handler do problema 3: se a rodada tiver revelada, a saída é adiada também.

 

O heartbeat descobre que a pessoa sumiu; o que fazer com isso continua sendo decisão da ordem das mutações.


Problema 5: eventos duplicados

E o último: cliente reconectou, ele manda join_room de novo... ou seja, o servidor pode receber o mesmo "entrar" duas vezes.

Se o código não tiver preparado, duplica o jogador na mesa.

 

Sabe o que segura isso? O mesmo find do problema 2: procura pelo userId antes de dar push.

 

Chegou de novo? Não cria, só reamarra.

 

Esse é o jeito simples de deixar um evento idempotente: dá pra aplicar duas vezes sem estragar nada.

Pra eventos com efeito colateral (tipo voto) o próximo passo seria dar um id pra cada evento e descartar o que já foi visto. Eu não precisei disso nessa versão, mas te falo: em rede real, evento duplicado é o padrão.


Conclusao

Então recapitula comigo os 5:

  1. Conexão não é presença — presença é estado teu, separado do socket.
  2. Identidade vem de fora — userId no localStorage, porque socket.id muda.
  3. Ordem importa — adia a mudança quando aplicar na hora estraga o que tá na tela.
  4. Heartbeat — conexão morta não avisa que morreu.
  5. Idempotência — evento chega duas vezes, teu código tem que aguentar.

Nada disso é exclusivo de planning poker. É o que aparece em qualquer sistema em tempo real.

E se tu leva uma coisa desse post, que seja essa: o difícil num app WebSocket não é mandar mensagem. É saber quem ainda tá do outro lado e em que ordem as coisas aconteceram.