Artigo

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

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.

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:

  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:

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:

  1. Conexão não é presença — presença é estado teu, separado do socket.
  2. Identidade vem de forauserId 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.

Racoelho

Conteúdo sobre desenvolvimento, tecnologia e desafios de programação para impulsionar sua carreira em tech.

Conecte-se

© 2024- 2026 Racoelho. Todos os direitos reservados.

v3.0.19 • Build: 2026-04-17