3

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:

  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:

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:

  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.

Carregando publicação patrocinada...