O difícil de WebSocket não é mandar mensagem
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.
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:
// 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:
- 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:
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.
sequenceDiagram
participant C as Cliente
participant S as Servidor
C->>S: join_room(userId: "abc", socket.id #1)
Note over S: find(userId "abc") vazio → push
C--xS: Wi-Fi caiu
C->>S: join_room(userId: "abc", socket.id #2)
Note over S: find(userId "abc") já existe → só reamarra socketId → #2
S-->>S: disconnect atrasado do socket.id #1
Note over S: procura por socketId #1 → não acha → ignora
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:
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:
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:
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:
- 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.