Eventos enviados pelo servidor são a maneira mais simples de enviar dados do servidor para o navegador. Eles são executados em HTTP comum, reconectam-se automaticamente e não precisam de protocolo adicional. Para painéis, notificações, atualizações de progresso e streaming de respostas de IA – qualquer coisa em que os dados fluam em uma direção – o SSE geralmente é a escolha certa em vez dos WebSockets.
📋 Table of Contents
SSE ou WebSockets?
| SSE | WebSockets | |
|---|---|---|
| Direção | Somente servidor para cliente | Bidirecional |
| Protocolo | HTTP simples | Atualize para ws:// |
| Reconectar automaticamente | Integrado | Você implementa |
| Formato dos dados | Texto UTF-8 | Texto ou binário |
| Facilidade de proxy | Geralmente bem | Precisa de suporte para atualização |
| Complexidade | Baixo | Superior |
UsarSSE para painéis ao vivo, feeds de notificação, progresso de trabalho, acompanhamento de log e saída de IA token por token. UsarWebSockets quando o cliente também envia mensagens frequentes — chat, edição colaborativa, jogos multijogador.
Um híbrido comum e sensato: SSE para atualizações de servidor, solicitações HTTP POST comuns para ações de cliente. Isso cobre a maioria dos aplicativos sem um segundo protocolo.
O formato do fio
SSE é um fluxo de texto simples com um formato pequeno e estrito.
data: hello world
event: userUpdate
data: {"id":1,"name":"Ada"}
id: 42
retry: 5000
data: message with an id and a retry hint
Duas regras causam quase todos os bugs. Cada mensagem termina comdois novas linhas. E dados multilinhas precisam de umdata: prefixo em cada linha, e é por isso que as cargas JSON não devem conter novas linhas brutas.
Servidor: Node.js com Express
import express from 'express';
const app = express();
// Track connected clients so we can broadcast.
const clients = new Set();
app.get('/api/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
// Tell nginx not to buffer this response.
'X-Accel-Buffering': 'no',
});
// Flush headers immediately so the client's connection opens.
res.flushHeaders();
const client = { id: Date.now(), res };
clients.add(client);
send(res, { type: 'connected', at: new Date().toISOString() });
// A comment line every 25s keeps proxies from closing an idle connection.
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 25_000);
req.on('close', () => {
clearInterval(heartbeat);
clients.delete(client);
});
});
function send(res, data, event) {
if (event) res.write(`event: ${event}\n`);
// JSON.stringify never emits a raw newline, which keeps the frame valid.
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
export function broadcast(data, event) {
for (const client of clients) {
send(client.res, data, event);
}
}
app.listen(3000);
Três detalhes são importantes aqui. flushHeaders() abre o fluxo imediatamente, em vez de esperar pela primeira gravação. O comentário de pulsação evita que intermediários abandonem uma conexão inativa. EX-Accel-Buffering: no impede que o nginx armazene em buffer a resposta, que é o motivo mais comum pelo qual o SSE funciona localmente e falha na produção.
Cliente: Reagir
import { useEffect, useRef, useState } from 'react';
export function useEventStream(url) {
const [messages, setMessages] = useState([]);
const [status, setStatus] = useState('connecting');
const sourceRef = useRef(null);
useEffect(() => {
const source = new EventSource(url, { withCredentials: true });
sourceRef.current = source;
source.onopen = () => setStatus('open');
source.onmessage = (e) => {
const data = JSON.parse(e.data);
setMessages(prev => [...prev, data]);
};
// Named events need their own listener.
source.addEventListener('userUpdate', (e) => {
const data = JSON.parse(e.data);
setMessages(prev => [...prev, { ...data, kind: 'userUpdate' }]);
});
source.onerror = () => {
// EventSource reconnects on its own unless the state is CLOSED.
setStatus(source.readyState === EventSource.CLOSED ? 'closed' : 'reconnecting');
};
return () => source.close();
}, [url]);
return { messages, status };
}
export function LiveFeed() {
const { messages, status } = useEventStream('/api/events');
return (
<div>
<p>Status: {status}</p>
<ul>
{messages.map((m, i) => <li key={i}>{JSON.stringify(m)}</li>)}
</ul>
</div>
);
}
Retornandosource.close() do efeito não é opcional. Sem ele, a montagem dupla do React Strict Mode deixa uma conexão órfã e a navegação pelo aplicativo acumula fluxos abertos até que o limite de conexão por domínio do navegador seja atingido e tudo pare.
Retomando após uma desconexão
Quando você envia umid: campo, o navegador o armazena e o envia de volta comoLast-Event-ID na reconexão. Isso permite que você reproduza apenas o que foi perdido.
app.get('/api/events', (req, res) => {
// ... headers as above ...
const lastId = req.headers['last-event-id'];
if (lastId) {
for (const event of getEventsSince(Number(lastId))) {
res.write(`id: ${event.id}\n`);
res.write(`data: ${JSON.stringify(event.payload)}\n\n`);
}
}
});
Isso transforma o SSE de melhor esforço em algo mais próximo da entrega confiável, o que é importante para feeds de notificação em que uma mensagem descartada fica visível para o usuário.
Autenticação
O nativoEventSource A API não pode definir cabeçalhos personalizados, o que surpreende as pessoas que criam APIs autenticadas por token. Três abordagens viáveis:
Biscoitos – mais simples. PassarwithCredentials: true e deixe o cookie de sessão autenticar a solicitação como faria com qualquer outro.
Um token de curta duração na string de consulta — aceitável apenas se o token for de uso único e expirar em minutos, porque os URLs acabam nos logs do servidor.
buscar com um leitor de streaming — controle total do cabeçalho, ao custo de você mesmo implementar a reconexão.
async function streamWithAuth(url, token, onMessage) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split('\n\n');
buffer = frames.pop() ?? ''; // keep the incomplete frame
for (const frame of frames) {
const line = frame.split('\n').find(l => l.startsWith('data:'));
if (line) onMessage(JSON.parse(line.slice(5).trim()));
}
}
}
Observe o buffer: um pedaço de rede pode dividir um quadro ao meio, portanto você deve manter o restante e analisar apenas os quadros completos. A análise de cada pedaço de forma independente produz erros JSON intermitentes que são difíceis de diagnosticar.
Streaming de respostas de IA
O padrão por trás da saída token por token nas interfaces de chat.
app.post('/api/chat', async (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
try {
for await (const chunk of generateResponse(req.body.prompt)) {
res.write(`data: ${JSON.stringify({ token: chunk })}\n\n`);
}
res.write('data: [DONE]\n\n');
} catch (err) {
res.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`);
} finally {
res.end();
}
});
Configuração de proxy e implantação
A maioria das falhas de SSE de produção são buffers de proxy. O cabeçalho por si só nem sempre é suficiente.
# nginx
location /api/events {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 24h;
chunked_transfer_encoding off;
}
Esteja ciente também de que as plataformas sem servidor geralmente limitam a duração da resposta, o que torna as conexões SSE de longa duração inadequadas. Verifique o limite da sua plataforma antes de projetar em torno dela. E com o HTTP/1.1, os navegadores permitem apenas cerca de seis conexões por domínio – o HTTP/2 remove essa restrição, portanto, sirva SSE sobre HTTP/2 sempre que possível.
Erros Comuns
Esquecendo a segunda nova linha. A mensagem nunca é despachada e o cliente parece travar.
Não fechar o EventSource ao desmontar. As conexões se acumulam até que o limite do navegador seja atingido.
Novas linhas brutas dentro dos dados. SempreJSON.stringify a carga útil.
Sem batimentos cardíacos. As conexões ociosas são fechadas por intermediários após um ou dois minutos.
Buffer de proxy deixado ativado. Tudo funciona localmente, então nada chega em produção até que a resposta termine.
Usando SSE para tráfego bidirecional. Se o cliente envia mensagens com frequência, use WebSockets.
Conclusão
O SSE oferece streaming de servidor para cliente através de HTTP comum com reconexão automática e muito pouco código. Acerte cinco coisas:termine cada mensagem com duas novas linhas, envie um comentário periódico de pulsação, desative o buffer de proxy com o cabeçalho e a configuração do nginx, feche o EventSource na limpeza do efeito e useid: comLast-Event-ID quando mensagens perdidas seriam notadas. Alcance WebSockets somente quando o cliente realmente precisar enviar mensagens frequentes de volta.
🔗 Share this article
✍️ Leave a Comment