Chat na página: polling, SSE ou WebSocket e a ordem das mensagens
Escolha entre polling, Server-Sent Events e WebSocket pelo uso real, grave a mensagem antes de mostrar, evite duplicata no reenvio e recupere o que perdeu quando a rede volta.
O chat de suporte da loja pergunta ao servidor, a cada segundo, se há mensagem nova. Com 10 atendentes e clientes com a aba aberta, são 864 mil requisições por dia, quase todas respondendo "nada". Quando a conexão de um cliente oscila, a tela continua parada como se ninguém tivesse respondido, e a mensagem que ele mandou duas vezes aparece duplicada para o atendente.
A resposta curta: polling rápido não transforma a página em conversa em tempo real. Para recado interno de baixo volume, polling a cada poucos segundos, com recuo quando falha, resolve. Para conversa de verdade, use um canal persistente: Server-Sent Events para receber, ou WebSocket quando os dois lados falam o tempo todo. Em qualquer caso, a mensagem é gravada no banco antes de aparecer, a ordem vem do id do banco, o reenvio usa um identificador do cliente para não duplicar, e reconectar busca o que ficou para trás.
Por que o polling engana
No polling, o navegador pergunta de tempos em tempos. Se o intervalo é longo, a conversa atrasa. Se é curto, o servidor gasta CPU, conexões e consultas ao banco respondendo vazio. Um segundo de intervalo ainda significa até um segundo de atraso, mais o tempo da requisição. Você paga caro e continua não tendo tempo real.
Há outro problema, mais grave que o custo: quando uma requisição falha, muitos chats caseiros simplesmente ignoram o erro. A tela fica igual, sem indicar que perdeu a conexão. A pessoa acha que o outro lado está calado, quando na verdade ela está desconectada. O mesmo vale para requisições que nunca respondem; sem tempo limite, elas ficam penduradas, como explica timeout antes do infinito.
Qual transporte usar
- Polling com intervalo de alguns segundos: bom para mural de recados, status de pedido, painel interno com poucos usuários. Simples, funciona em qualquer hospedagem.
- Server-Sent Events (SSE): uma conexão HTTP que fica aberta e por onde o servidor empurra mensagens. O envio continua sendo um POST comum. O
EventSourcedo navegador reconecta sozinho e, se as mensagens tiveremid, manda o cabeçalhoLast-Event-IDna reconexão, o que facilita retomar de onde parou. - WebSocket: canal bidirecional, ideal quando há muito tráfego nos dois sentidos, como indicador de "digitando". Bibliotecas como SignalR e Socket.IO cuidam de reconexão e de alternativas quando o WebSocket não está disponível.
Para chat de suporte, SSE costuma ser suficiente e mais simples de operar que WebSocket. Para a parte de servidor com SignalR, veja notificação em tempo real com SignalR.
Como gravar e ordenar as mensagens
O desenho abaixo vale para qualquer transporte. O banco é a fonte da verdade; o canal só avisa.
CREATE TABLE mensagens (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
sala_id INT NOT NULL,
autor_id INT NOT NULL,
id_cliente CHAR(36) NOT NULL,
texto VARCHAR(2000) NOT NULL,
criada_em DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uq_reenvio (autor_id, id_cliente),
KEY ix_sala (sala_id, id)
) DEFAULT CHARSET=utf8mb4;
- O navegador gera um
id_clientecomcrypto.randomUUID()antes de enviar. - Mostra a mensagem como "enviando", em cinza, sem fingir que foi entregue.
- O servidor grava. Se o par autor e
id_clientejá existe, devolve a mensagem existente em vez de criar outra. - O navegador troca o estado para enviado ao receber o id do banco.
- Se falhar, reenvia com o mesmo
id_cliente. A restrição única garante uma mensagem só, o mesmo princípio de idempotência no pagamento. - A tela ordena pelo id do banco, nunca pela ordem em que as respostas chegaram.
Um cliente com polling que não mente
Mesmo no polling simples, dá para fazer o certo: intervalo razoável, recuo exponencial quando falha, aviso visível de desconexão e remoção de duplicatas.
const SALA = 7;
const recebidas = new Map();
let ultimoId = 0;
let espera = 3000;
async function buscar() {
try {
// volta alguns ids para pegar mensagens confirmadas fora de ordem; o Map descarta repetidas
const desde = Math.max(0, ultimoId - 50);
const resposta = await fetch('/api/salas/' + SALA + '/mensagens?depois=' + desde, {
signal: AbortSignal.timeout(10000)
});
if (!resposta.ok) throw new Error('HTTP ' + resposta.status);
for (const m of await resposta.json()) {
recebidas.set(m.id, m);
if (m.id > ultimoId) ultimoId = m.id;
}
desenhar();
espera = document.visibilityState === 'visible' ? 3000 : 15000;
mostrarStatus('');
} catch (erro) {
espera = Math.min(espera * 2, 60000);
mostrarStatus('Sem conexão. Nova tentativa em ' + Math.round(espera / 1000) + ' s.');
}
setTimeout(buscar, espera);
}
function desenhar() {
const ordenadas = [...recebidas.values()].sort((a, b) => a.id - b.id);
const lista = document.getElementById('mensagens');
lista.replaceChildren(...ordenadas.map((m) => {
const li = document.createElement('li');
li.textContent = m.autor + ': ' + m.texto;
return li;
}));
}
function mostrarStatus(texto) {
document.getElementById('status').textContent = texto;
}
buscar();
O textContent não é detalhe: montar a mensagem com innerHTML deixa qualquer participante injetar script na tela dos outros. A página precisa de um ul com id mensagens e de um elemento com id status, de preferência com aria-live="polite" para que o aviso de desconexão seja anunciado.
Por que voltar alguns ids
Com várias pessoas enviando ao mesmo tempo, o banco pode reservar o id 104 para uma transação e o 105 para outra, e a 105 terminar primeiro. Se o cliente buscou "depois de 104" nesse intervalo, recebeu a 105, avançou o marcador e nunca mais pede a 104. Buscar com uma pequena sobreposição e descartar repetidas fecha esse buraco em chats de volume baixo. Em volume alto, a solução é uma sequência por sala atribuída de forma serializada, ou o canal persistente entregando cada mensagem confirmada.
Reconectar é recuperar a história
Com qualquer transporte, a rede vai cair. Ao reconectar, o cliente pede ao servidor as mensagens depois do último id que conhece, junta com as que já tem e redesenha em ordem. No SSE, o Last-Event-ID chega pronto; no WebSocket, o cliente envia o último id ao reconectar. O que nunca pode acontecer é a tela voltar a receber mensagens novas sem as que se perderam no meio.
Erros comuns
- Polling a cada segundo para todos: centenas de milhares de requisições vazias por dia, banco ocupado e ainda assim atraso perceptível.
- Ignorar erro de rede: a pessoa acha que o outro lado não responde quando, na verdade, está desconectada.
- Mostrar a mensagem como enviada antes de gravar: se o servidor recusa, a conversa na tela não bate com a do outro lado.
- Reenvio sem identificador do cliente: cada nova tentativa cria uma mensagem duplicada.
- Ordenar pela chegada da resposta: duas mensagens enviadas juntas aparecem invertidas para pessoas diferentes.
- Montar a bolha com
innerHTML: qualquer participante executa script no navegador dos outros.
Checklist
- O intervalo de polling é de segundos, aumenta quando falha e quando a aba está oculta, ou o chat usa SSE ou WebSocket.
- A tela mostra claramente quando está sem conexão.
- Toda mensagem é gravada antes de aparecer como enviada.
- O reenvio usa o mesmo identificador gerado no cliente e o banco tem restrição única.
- A ordem na tela é a do id do banco.
- Ao reconectar, o cliente busca as mensagens perdidas.
- O texto das mensagens é inserido com
textContent. - Toda requisição tem tempo limite.
Abra o chat em duas abas, desligue a rede de uma por trinta segundos enquanto a outra envia três mensagens e religue. As três aparecem, na mesma ordem nas duas abas, ou o chat montou a história na corrida?
Referência: http://criarestilosnet.com/criar-chat-em-jquery/