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.
1const players = new Map(); // socketId -> player 2 3io.on("connection", (socket) => { 4 players.set(socket.id, { name, vote: null }); 5 6 socket.on("disconnect", () => { 7 players.delete(socket.id); 8 }); 9});
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:
1// sessionId -> { sessionId, sessionName, participants: [], isRevealed } 2const sessions = {}; 3// sessionId -> Set<userId> de saídas adiadas 4const pendingRemovals = {}; 5 6// participante = { userId, userName, socketId, selectedCard } 7 8// toda sessão nasce por aqui — e o Set de saídas adiadas nasce junto 9function getOrCreateSession (sessionId) { 10 if (!sessions[sessionId]) { 11 sessions[sessionId] = { sessionId, sessionName: "", participants: [], isRevealed: false }; 12 pendingRemovals[sessionId] = new Set(); 13 } 14 return sessions[sessionId]; 15} 16 17// manda o estado atual da sessão pra todo mundo na sala 18function updateSession (sessionId) { 19 io.to(sessionId).emit("session_update", sessions[sessionId]); 20}
Repara em duas coisas:
- O
socketIdfica guardado só como referência da conexão atual; a chave de verdade é ouserId. - Tem um segundo mapa
pendingRemovalscom as saídas adiadas de cada sessão. Ele nasce junto com a sessão (pra nunca serundefinedna 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:
1function addParticipant (sessionId, userId, userName, socketId) { 2 const session = getOrCreateSession(sessionId); 3 const existing = session.participants.find((p) => p.userId === userId); 4 if (existing) { 5 // reconectou: só reamarra o socket novo 6 existing.socketId = socketId; 7 } else { 8 session.participants.push({ userId, userName, socketId, selectedCard: null }); 9 } 10}
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:
1socket.on("disconnect", () => { 2 const session = Object.values(sessions).find((s) => 3 s.participants.some((p) => p.socketId === socket.id) 4 ); 5 6 // socket já foi reamarrado (problema 2): ignora 7 if (!session) return; 8 9 const userId = session.participants.find((p) => p.socketId === socket.id)?.userId; 10 if (!userId) return; 11 12 if (session.isRevealed) { 13 // cartas na mesa: NÃO remove agora, adia 14 pendingRemovals[session.sessionId].add(userId); 15 } else { 16 // votação aberta: remove na hora 17 session.participants = session.participants.filter((p) => p.userId !== userId); 18 updateSession(session.sessionId); 19 } 20});
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:
1socket.on("reset_voting", ({ sessionId }) => { 2 sessions[sessionId].isRevealed = false; 3 sessions[sessionId].participants.forEach((p) => (p.selectedCard = null)); 4 5 // só agora as saídas adiadas acontecem de verdade 6 sessions[sessionId].participants = sessions[sessionId].participants.filter( 7 (p) => !pendingRemovals[sessionId].has(p.userId) 8 ); 9 pendingRemovals[sessionId].clear(); 10 updateSession(sessionId); 11});
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:
1const io = new Server(httpServer, { 2 pingInterval: 25000, // ping a cada 25s 3 pingTimeout: 20000, // sem pong em 20s → conexão morta → dispara disconnect 4});
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:
- Conexão não é presença — presença é estado teu, separado do socket.
- Identidade vem de fora —
userIdnolocalStorage, porquesocket.idmuda. - Ordem importa — adia a mudança quando aplicar na hora estraga o que tá na tela.
- Heartbeat — conexão morta não avisa que morreu.
- 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.