Server-Sent Events, ou SSE, permite que um servidor envie eventos contínuos para o navegador por uma conexão HTTP. O cliente usa EventSource, enquanto o servidor responde com o tipo text/event-stream.
SSE é indicado para notificações, progresso, dashboards, logs e atualizações unidirecionais. Quando o cliente também precisa enviar mensagens frequentes pela mesma conexão, WebSocket pode ser mais adequado.
Formato de evento
id: 42
event: order.updated
data: {"orderId":123,"status":"paid"}
Uma linha vazia encerra o evento. Campos comuns:
data: conteúdo;event: nome do evento;id: identificador para reconexão;retry: intervalo de reconexão sugerido.
Servidor HTTP básico
import { createServer } from 'node:http';
const server = createServer((req, res) => {
if (req.url !== '/events') {
res.writeHead(404).end();
return;
}
res.writeHead(200, {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache',
connection: 'keep-alive',
});
res.write('retry: 5000\n\n');
const timer = setInterval(() => {
res.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`);
}, 10_000);
req.on('close', () => {
clearInterval(timer);
});
});
server.listen(3000);O listener de close é essencial para remover timers e inscrições quando o cliente desconecta.
Cliente no navegador
const events = new EventSource('/events');
events.onmessage = (event) => {
console.log(JSON.parse(event.data));
};
events.onerror = (error) => {
console.error('SSE falhou', error);
};EventSource tenta reconectar automaticamente. O servidor precisa tratar reconexões e evitar duplicação.
Eventos nomeados
res.write('event: order.updated\n');
res.write(`data: ${JSON.stringify(order)}\n\n`);No cliente:
events.addEventListener('order.updated', (event) => {
const order = JSON.parse(event.data);
updateOrder(order);
});IDs e Last-Event-ID
Quando o servidor envia id, o navegador pode informar o último evento recebido ao reconectar:
Last-Event-ID: 42O servidor pode retomar após esse ID. Para isso, eventos precisam ser armazenados em log, banco ou fila com retenção.
Replay
const lastEventId = req.headers['last-event-id'];
const missedEvents = await eventStore.listAfter(lastEventId);
for (const event of missedEvents) {
writeEvent(res, event);
}Defina limite de replay. Um cliente muito atrasado pode precisar buscar um snapshot completo e então continuar pelo stream.
Função para escrever eventos
function writeEvent(res, {
id,
event,
data,
retry,
}) {
if (id !== undefined) res.write(`id: ${id}\n`);
if (event) res.write(`event: ${event}\n`);
if (retry) res.write(`retry: ${retry}\n`);
const text = typeof data === 'string'
? data
: JSON.stringify(data);
for (const line of text.split('\n')) {
res.write(`data: ${line}\n`);
}
return res.write('\n');
}Dividir linhas mantém o formato válido.
Backpressure
res.write() pode retornar false. Um cliente lento não deve acumular dados ilimitados:
if (!writeEvent(res, event)) {
await new Promise((resolve) => res.once('drain', resolve));
}Para milhares de clientes, não bloqueie uma rotina global aguardando cada um em sequência. Mantenha filas limitadas por conexão e desconecte consumidores excessivamente lentos.
Heartbeat
Proxies podem fechar conexões ociosas. Envie comentários periódicos:
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 15_000);O intervalo deve ser menor que o timeout ocioso da infraestrutura, sem gerar tráfego excessivo.
Proxy buffering
Alguns proxies acumulam pequenos chunks e impedem eventos em tempo real. Desative buffering para a rota SSE conforme o proxy. Também evite compressão que só libera dados após formar blocos grandes.
Compressão
Compressão pode atrasar eventos. Se usar, configure flush adequado e teste. Muitas aplicações desativam compressão em text/event-stream.
CORS
Para origem diferente, configure CORS:
res.setHeader('access-control-allow-origin', 'https://app.example.com');
res.setHeader('vary', 'Origin');Não use origem curinga quando a conexão depende de credenciais.
Autenticação
EventSource do navegador possui limitações para headers personalizados. Alternativas:
- cookie seguro e SameSite adequado;
- URL temporária assinada;
- token curto em query com cuidado;
- Fetch streaming quando headers customizados são necessários.
Tokens em query podem aparecer em logs e histórico. Prefira URL de uso único e curta duração.
Cookies
Proteja a rota contra CSRF e vazamento entre origens. Valide Origin, autenticação e autorização para cada canal.
Autorização por evento
Não basta autenticar na conexão. O servidor deve garantir que o cliente pode receber cada recurso. Não transmita um evento global e filtre apenas no frontend.
Gerenciando clientes
const clients = new Map();
function addClient(userId, res) {
const set = clients.get(userId) || new Set();
set.add(res);
clients.set(userId, set);
res.once('close', () => {
set.delete(res);
if (set.size === 0) clients.delete(userId);
});
}Imponha limites de conexões por usuário e por IP.
Broadcast
function broadcast(event) {
for (const client of allClients) {
if (!writeEvent(client, event)) {
// marcar cliente lento ou limitar fila
}
}
}Um loop por milhares de clientes consome CPU. Agrupe, use estruturas eficientes e faça benchmark.
Múltiplas instâncias
Se clientes estão distribuídos entre pods, eventos precisam chegar a todos. Use Redis Pub/Sub, NATS, Kafka ou outro barramento, conforme requisitos de durabilidade.
Pub/Sub simples perde mensagens durante desconexão. Para replay confiável, use log durável.
Sticky sessions
SSE é uma conexão persistente, portanto permanece no backend escolhido. Não depende de sticky para reconectar, desde que qualquer instância consiga recuperar estado e replay.
Limites de conexão
Navegadores e proxies possuem limites por origem e protocolo. HTTP/2 multiplexa conexões melhor, mas teste o ambiente real.
Graceful shutdown
Ao encerrar, pare de aceitar conexões, envie um evento de fechamento opcional, termine as respostas e permita reconexão em outra instância.
for (const res of allClients) {
writeEvent(res, {
event: 'server.restart',
data: { reconnect: true },
retry: 1000,
});
res.end();
}Rate limiting
Limite novas conexões, reconexões rápidas e assinaturas. Um cliente com erro pode criar loop de reconexão. Use backoff e retry.
Observabilidade
Meça:
- conexões abertas;
- conexões por usuário;
- duração;
- reconexões;
- eventos enviados;
- bytes;
- clientes lentos;
- filas descartadas;
- replay;
- erros e encerramentos.
Teste
Teste reconexão, Last-Event-ID, proxy, heartbeat, cliente lento, múltiplas instâncias, autorização, shutdown e rede instável. Use uma conexão real, pois mocks simples não reproduzem buffering.
SSE ou WebSocket
Use SSE para fluxo servidor-cliente, reconexão automática e integração HTTP simples. Use WebSocket para comunicação bidirecional frequente, jogos, colaboração e protocolos customizados.
Erros comuns
- não enviar linha vazia;
- não limpar timers;
- ignorar backpressure;
- usar compressão com buffering;
- não enviar heartbeat;
- armazenar clientes sem remover;
- não proteger tokens em query;
- não suportar replay;
- broadcast sem autorização;
- não tratar shutdown.
Fluxo recomendado
Use text/event-stream, IDs, heartbeat e filas limitadas. Planeje replay, autenticação e múltiplas instâncias. Combine com Streams e Backpressure, Graceful Shutdown, Rate Limiting e HTTP/2.
Consulte o guia de Server-Sent Events da MDN e a especificação de SSE.


