Voltar para o blog

SSE para eventos em tempo real, sem WebSocket

Equipe AltiviaCloud · 5 de setembro de 2026 · 3 min de leitura

O servidor empurra evento pelo HTTP de sempre e o navegador já sabe receber. Onde o SSE ganha do polling, onde ele não serve, e os dois detalhes que só quebram em produção.

Quando o usuário está olhando a tela esperando um código chegar, cada segundo conta. Consultar a API em laço funciona, mas gasta requisição e ainda assim atrasa. WebSocket resolve, mas traz uma conexão bidirecional que você não precisa — e que não sobrevive bem em ambiente serverless.

Server-Sent Events fica no meio: o servidor empurra evento pelo mesmo HTTP de sempre, e o navegador já sabe fazer isso sem biblioteca nenhuma.

O lado do cliente é uma API só

const stream = new EventSource(`/api/orders/${pedidoId}/stream`);

stream.onmessage = (evento) => {
  const dados = JSON.parse(evento.data);
  if (dados.event === "sms:received") {
    mostrarCodigo(dados.data.code);
    stream.close();
  }
};

stream.onerror = () => {
  // o navegador reconecta sozinho; só trate se quiser desistir
};

Sem dependência, sem handshake, sem protocolo próprio. E a reconexão é automática: se a conexão cair, o navegador tenta de novo sozinho, o que é mais do que a maioria das implementações caseiras de WebSocket faz.

Não esqueça o close(). EventSource mantém a conexão aberta indefinidamente — inclusive depois que a página já mostrou o que precisava.

O lado do servidor

O essencial é responder com text/event-stream e ir escrevendo:

export async function GET() {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      const enviar = (dados: unknown) => {
        controller.enqueue(encoder.encode(`data: ${JSON.stringify(dados)}\n\n`));
      };

      enviar({ type: "connected" });

      const cancelar = assinarEventos(pedidoId, enviar);

      // fecha quando o cliente desiste
      return () => cancelar();
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
    },
  });
}

Dois detalhes que quebram em produção e não em desenvolvimento:

As duas quebras de linha no fim. data: ...\n\n — uma só não fecha o evento, e o cliente fica esperando o resto para sempre.

no-transform no Cache-Control. Sem isso, um proxy no caminho pode decidir bufferizar a resposta, e aí os eventos chegam todos juntos no final. O sintoma é cruel: funciona local, "atrasa" em produção.

Onde o SSE não serve

Quando ninguém está olhando. SSE vive na aba do navegador. Fechou a aba, perdeu o evento — não há reentrega, não há histórico. Para registrar em banco, processar em fila ou reagir do lado do servidor, o caminho é webhook, que sobrevive à aba fechada.

Quando o cliente precisa falar. SSE é mão única, do servidor para o cliente. Se o cliente também envia, ou você usa requisições normais para isso, ou o caso pede WebSocket de verdade.

Conexão longa em função serverless. Cada stream aberto ocupa uma execução, e execução tem teto de duração. Para uma espera de vinte minutos, avalie o custo — ou combine: SSE para a janela curta em que o usuário está olhando, webhook para o resto.

O padrão que funciona na prática

Os dois juntos, cada um no seu papel:

  1. SSE enquanto a pessoa está na tela, para o código aparecer no instante em que chega.
  2. Webhook para o seu servidor registrar o que aconteceu, com ou sem alguém olhando.

Uma armadilha nessa combinação: se a tela escuta pelos dois canais ao mesmo tempo e simplesmente adiciona o que recebe, a mesma mensagem entra duas vezes na lista. Deduplique por identificador do evento, não pela ordem de chegada — o mesmo cuidado de idempotência que o webhook exige, agora na interface.

Se for consultar mesmo assim

Nem todo caso justifica stream. Se você vai perguntar em laço, dois cuidados:

  • Intervalo de alguns segundos, não de milissegundos. Perguntar mais rápido não faz a operadora entregar antes, só consome sua cota até tomar 429.
  • Limite o laço pelo expiresAt que a API devolve, não por uma contagem fixa de tentativas.

O ganho do SSE aqui não é performance de servidor: é que a resposta aparece quando o evento acontece, e não no próximo intervalo do seu relógio.

É o mesmo canal que o painel dos números virtuais usa para mostrar o código assim que ele chega.