Server-Sent Events

Server-sent events (SSE) é uma tecnologia de push baseada em HTTP que permite ao servidor enviar atualizações automaticamente para o cliente. Neste artigo, você vai entender como o modelo funciona, como consumir eventos no navegador e como produzi-los em um servidor Node.js.

O que são server-sent events

Nas requisições tradicionais, o cliente é responsável por buscar dados no servidor sempre que precisa de algo novo. Os server-sent events invertem esse fluxo: depois que a conexão é estabelecida, o servidor pode transmitir eventos a qualquer momento.

Em outras palavras, a comunicação é unidirecional, sempre do servidor para o cliente. O navegador abre uma única conexão HTTP de longa duração e fica ouvindo o que chega por ela.

Por isso, o modelo é muito mais leve do que fazer polling a cada poucos segundos. Em vez de perguntar repetidamente se há novidades, o cliente simplesmente espera que o servidor avise.

O suporte a server-sent events está presente em todos os navegadores modernos, como Firefox, Chrome, Safari, Opera e Microsoft Edge. Além disso, a especificação faz parte do HTML Living Standard, mantido pelo WHATWG.

Como os server-sent events funcionam no navegador

No navegador, os server-sent events funcionam por meio da interface JavaScript EventSource. Ela abstrai o estabelecimento da conexão, a leitura do stream e a interpretação de cada mensagem.

Para começar, o cliente cria uma instância dessa interface apontando para um endpoint. Esse endpoint precisa responder com um stream de eventos usando o tipo MIME text/event-stream.

// Abre a conexão com o endpoint que publica os eventos
const evtSource = new EventSource("/events");

Assim que o objeto é criado, o navegador já faz a requisição e mantém a conexão aberta. Se você ainda está começando na linguagem, vale revisar o artigo o que é JavaScript antes de seguir.

A partir dessa conexão, o cliente pode escutar diferentes tipos de eventos. Os dois mais comuns são os eventos padrão e os eventos personalizados.

Eventos padrão com onmessage

Quando o servidor envia uma mensagem sem nome de evento, o navegador dispara o evento message. Nesse caso, basta atribuir uma função à propriedade onmessage.

// Recebe todas as mensagens sem nome de evento
evtSource.onmessage = (event) => {
  console.log("Message received:", event.data);
};

O conteúdo enviado pelo servidor chega sempre em event.data, como texto. Se o servidor enviou JSON, então você precisa convertê-lo com JSON.parse.

Eventos personalizados com addEventListener

Por outro lado, quando a mensagem tem um nome, como ping, ela não chega ao onmessage. Para recebê-la, você registra um ouvinte específico com addEventListener.

// Recebe apenas os eventos nomeados como "ping"
evtSource.addEventListener("ping", (event) => {
  const data = JSON.parse(event.data);
  console.log(`Ping received at ${data.time}`);
});

Dessa forma, um único stream pode transportar vários tipos de mensagem. Cada parte da interface escuta apenas o que interessa a ela.

Estados da conexão

A propriedade readyState indica em que ponto a conexão está. Ela assume três valores: CONNECTING (0), OPEN (1) e CLOSED (2).

Além do message, a interface também dispara o evento open quando a conexão é estabelecida. Com isso, você consegue exibir um indicador de status para o usuário, por exemplo.

Enviando server-sent events a partir do servidor

Do lado do servidor, o trabalho é escrever texto no formato correto em uma resposta que nunca termina. Primeiro, a resposta precisa usar o cabeçalho Content-Type: text/event-stream.

Em seguida, cada evento segue duas regras simples. Cada linha usa o formato campo: valor, e cada evento termina com duas quebras de linha.

O exemplo abaixo usa Node.js com Express para gerar server-sent events a cada segundo. Caso ainda não tenha o ambiente pronto, o artigo sobre instalação do Node e do npm mostra o passo a passo.

const express = require('express');
const app = express();

app.get('/events', (req, res) => {
  // Cabeçalhos obrigatórios para um stream de eventos
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  let counter = Math.floor(Math.random() * 10) + 1;

  const sendEvent = () => {
    const date = new Date().toISOString();

    // Evento nomeado "ping" com um JSON no campo data
    res.write('event: ping\n');
    res.write(`data: {"time": "${date}"}\n\n`);

    counter--;
    if (counter === 0) {
      // Mensagem sem nome de evento, recebida pelo onmessage
      res.write(`data: This is a random message at ${date}\n\n`);
      counter = Math.floor(Math.random() * 10) + 1;
    }
  };

  const interval = setInterval(() => {
    if (res.writableEnded) {
      clearInterval(interval);
      return;
    }
    sendEvent();
  }, 1000);

  // Interrompe o envio quando o cliente fecha a conexão
  req.on('close', () => {
    clearInterval(interval);
  });
});

app.listen(3000, () => {
  console.log('SSE server listening at http://localhost:3000/events');
});

Nesse código, o servidor envia um evento ping por segundo. Além disso, em intervalos aleatórios, ele envia uma mensagem sem nome, que cai no onmessage do cliente.

O ponto mais importante está no req.on('close'). Sem ele, o setInterval continuaria rodando depois que o usuário fechasse a aba, consumindo memória à toa.

Repare também no cabeçalho Cache-Control: no-cache. Ele evita que caches intermediários guardem a resposta, o que quebraria a entrega em tempo real.

Enviando id e retry junto com os eventos

O exemplo anterior envia apenas event e data, mas o servidor pode incluir os outros campos. Veja uma função auxiliar que monta cada evento com identificador e intervalo de reconexão.

// Monta um evento completo no formato text/event-stream
function writeEvent(res, { id, event, data, retry }) {
  if (retry) res.write(`retry: ${retry}\n`);
  if (id) res.write(`id: ${id}\n`);
  if (event) res.write(`event: ${event}\n`);
  res.write(`data: ${JSON.stringify(data)}\n\n`);
}

// Exemplo de uso dentro da rota /events
writeEvent(res, {
  id: 42,
  event: 'orderstatus',
  data: { orderId: 1001, status: 'shipped' },
  retry: 5000
});

Com essa função, o servidor deixa o envio padronizado e evita esquecer a linha em branco final. Além disso, o id permite que o cliente informe, na reconexão, qual foi o último evento processado.

Consumindo server-sent events em uma página

Para fechar o ciclo, veja uma página HTML mínima que consome o endpoint criado com Express. Ela mostra cada mensagem em uma lista e exibe o status da conexão.

<ul id="messages"></ul>
<p id="status">Connecting...</p>

<script>
  const list = document.getElementById("messages");
  const status = document.getElementById("status");
  const source = new EventSource("http://localhost:3000/events");

  // Atualiza o status quando a conexão abre
  source.onopen = () => (status.textContent = "Connected");

  // Adiciona cada ping recebido na lista
  source.addEventListener("ping", (event) => {
    const item = document.createElement("li");
    item.textContent = JSON.parse(event.data).time;
    list.appendChild(item);
  });

  // Indica que o navegador está tentando reconectar
  source.onerror = () => (status.textContent = "Reconnecting...");
</script>

Ao abrir essa página, a lista cresce a cada segundo, sem nenhuma requisição extra do cliente. Se você derrubar o servidor e subir de novo, o navegador reconecta sozinho e a lista volta a receber itens.

Como a página e o servidor rodam em portas diferentes, o navegador trata a chamada como uma requisição entre origens. Nesse caso, configure o CORS no Express ou sirva a página pelo mesmo servidor.

Formato do stream de server-sent events

O protocolo dos server-sent events é propositalmente simples. Cada mensagem pode conter os seguintes campos:

  • event: nome do evento. Quando presente, o cliente precisa usar addEventListener com esse nome.
  • data: dados associados, como texto puro ou JSON serializado.
  • id: identificador da mensagem, usado na retomada após uma queda.
  • retry: tempo, em milissegundos, que o navegador espera antes de tentar reconectar.

Qualquer outro nome de campo é ignorado pelo navegador. Além disso, uma linha que começa com dois-pontos é tratada como comentário.

Esse detalhe dos comentários é útil na prática. Enviar um comentário periodicamente mantém a conexão viva quando não há eventos reais para transmitir.

Vale lembrar que o formato foi desenhado para texto em UTF-8. Portanto, transmitir dados binários por server-sent events é possível, mas ineficiente.

Exemplo 1: mensagens simples

O stream abaixo começa com um comentário e depois envia duas mensagens. A segunda mensagem ocupa duas linhas de data.

: test connection

data: simple message

data: another line
data: with two lines

Quando várias linhas data aparecem seguidas, o navegador junta todas em uma única mensagem. Nesse caso, ele insere uma quebra de linha entre cada parte.

Exemplo 2: eventos nomeados

Agora, um stream com dois eventos nomeados, cada um carregando um JSON. Imagine um chat que avisa quando alguém entra e quando alguém envia uma mensagem.

event: userconnect
data: {"username": "ana", "time": "10:15:00"}

event: usermessage
data: {"username": "ana", "time": "10:15:05", "text": "Hi everyone!"}

No cliente, você registraria um addEventListener para userconnect e outro para usermessage. Assim, cada evento atualiza uma parte diferente da tela.

Reconexão e Last-Event-ID

Um dos grandes diferenciais dos server-sent events é a reconexão automática. Quando a conexão cai, o navegador tenta se conectar de novo sozinho, sem nenhum código extra.

Além disso, se o servidor enviou o campo id, o navegador guarda o último valor recebido. Na reconexão, ele envia esse valor no cabeçalho Last-Event-ID.

Com isso, o servidor sabe exatamente de onde o cliente parou. Então ele pode reenviar as mensagens perdidas ou simplesmente descartá-las, conforme a regra do negócio.

O intervalo entre as tentativas também pode ser controlado pelo servidor. Por exemplo, retry: 10000 faz o navegador esperar dez segundos antes de tentar novamente.

Tratamento de erros

Quando ocorre um erro de conexão, a interface dispara o evento error. Você pode tratá-lo pela propriedade onerror.

// Disparado em falhas de rede, respostas inválidas ou bloqueios de CORS
evtSource.onerror = (err) => {
  console.error("EventSource failed:", err);
};

Esse evento aparece em várias situações. Por exemplo, quando o servidor responde com erro, quando a rede expira ou quando uma política de CORS bloqueia a requisição.

Na maioria das quedas de rede, o navegador continua tentando reconectar depois do error. Por isso, use o readyState para saber se a conexão está sendo retomada ou se foi encerrada de vez.

Em cenários entre domínios diferentes, o servidor precisa responder com o cabeçalho Access-Control-Allow-Origin adequado. Se você também precisa enviar cookies, crie o objeto com { withCredentials: true } no segundo parâmetro.

Fechando a conexão

Por padrão, a conexão fica aberta até que a página seja fechada. Para encerrá-la manualmente, basta chamar o método close.

// Encerra a conexão e impede novas tentativas de reconexão
evtSource.close();

Depois do close, o readyState passa para CLOSED e o navegador não tenta mais reconectar. Assim, se quiser voltar a ouvir eventos, você precisa criar uma nova instância de EventSource.

Essa chamada é importante em aplicações de página única. Quando o usuário sai de uma tela que usa server-sent events, feche a conexão para liberar recursos no cliente e no servidor.

Server-sent events e HTTP/2

Os server-sent events utilizam conexões HTTP persistentes, e isso tem uma consequência importante. Em HTTP/1.x, os navegadores limitam as conexões simultâneas por domínio, geralmente a seis.

Esse limite vale para o navegador inteiro, e não para cada aba. Ou seja, um usuário com várias abas da mesma aplicação abertas pode esgotar as conexões disponíveis.

Quando isso acontece, as demais requisições da página ficam na fila. Na prática, a interface parece travada, mesmo com o servidor funcionando normalmente.

A documentação da MDN registra que esse comportamento foi marcado como "Won't fix" tanto no Chrome quanto no Firefox. Portanto, não espere uma correção do lado dos navegadores.

Em HTTP/2, por outro lado, o limite é bem mais elevado e negociado entre cliente e servidor. Por padrão, o número de streams simultâneos costuma ser 100, todos multiplexados em uma única conexão TCP.

Por esse motivo, servir server-sent events sobre HTTP/2 é a recomendação mais prática. Outra alternativa é compartilhar uma única conexão entre abas usando BroadcastChannel ou SharedWorker.

Server-sent events ou WebSockets

Os WebSockets também oferecem comunicação em tempo real, mas resolvem um problema diferente. Eles abrem um canal bidirecional, em que cliente e servidor enviam mensagens a qualquer momento.

Além disso, os WebSockets suportam dados binários, o que não é o forte do SSE. Em contrapartida, exigem um protocolo próprio, que nem todo proxy ou balanceador trata da mesma forma.

Já os server-sent events rodam sobre HTTP comum. Com isso, aproveitam autenticação por cookies, compressão e infraestrutura que você já tem, além de oferecer reconexão automática.

Se quiser comparar com a outra abordagem, o artigo sobre WebSockets no ASP.NET mostra como ela funciona no ecossistema .NET. Para quem usa .NET, também vale ler sobre server-sent events no .NET 10.

Quando usar server-sent events

Os server-sent events são ideais quando o cliente só precisa ouvir o servidor. Alguns exemplos clássicos são notificações, painéis de monitoramento, cotações e placares ao vivo.

Também funcionam muito bem em chats, desde que o envio de mensagens do usuário aconteça por requisições HTTP comuns. Dessa forma, o SSE cuida apenas da entrega das mensagens novas.

Outro uso cada vez mais comum é o streaming de respostas de modelos de IA. Nesse caso, o texto chega aos poucos, palavra por palavra, enquanto o modelo gera a resposta.

Esse padrão aparece, por exemplo, nas APIs de streaming de provedores de IA, que entregam cada trecho gerado como um evento separado. Assim, a interface mostra o texto em construção, e o usuário não fica olhando para uma tela parada enquanto espera a resposta completa.

Por outro lado, evite SSE quando o cliente precisa enviar muitas mensagens em alta frequência. Jogos multiplayer e edição colaborativa, por exemplo, se beneficiam mais de um canal bidirecional.

Antes de levar a solução para produção, considere ainda autenticação, proxies e segmentação por usuário. O artigo sobre os desafios dos server-sent events em produção aprofunda esses pontos.

Conclusão

Server-sent events são uma solução simples e eficaz para transmissão unidirecional de dados em tempo real. Com poucas linhas de JavaScript, o navegador recebe atualizações sem precisar perguntar ao servidor.

São ideais para chats, notificadores, sistemas de monitoramento ou qualquer funcionalidade que demande atualização contínua. Em muitos desses casos, você nem precisa de WebSockets.

Com suporte nativo nos navegadores modernos, reconexão automática e implementação simples, o SSE oferece uma alternativa leve para comunicação reativa. Para aprofundar, consulte a documentação da MDN sobre server-sent events.

FAQ

Server-sent events funcionam em todos os navegadores?

Sim, todos os navegadores modernos, como Chrome, Firefox, Safari, Opera e Edge, suportam a interface EventSource nativamente.

O EventSource permite enviar cabeçalhos personalizados?

Não, a interface EventSource não aceita cabeçalhos customizados, então a autenticação costuma usar cookies ou parâmetros na URL.

Qual a diferença entre SSE e WebSockets?

O SSE é unidirecional e roda sobre HTTP comum, enquanto o WebSocket é bidirecional, aceita dados binários e usa um protocolo próprio.

O que acontece quando a conexão cai?

O navegador reconecta automaticamente e envia o último id recebido no cabeçalho Last-Event-ID para o servidor retomar a entrega.

Por que minha aplicação trava com várias abas abertas?

Em HTTP/1.x, o navegador limita as conexões por domínio a cerca de seis, e cada aba com SSE ocupa uma delas; o HTTP/2 resolve esse problema.