Tempo real na web · parte 1 de 1
SSE e EventSource: como o servidor fala com o navegador sem ser pedido
Server-Sent Events do HTTP comum ao hub em Go: formato do stream, API EventSource, reconexão com Last-Event-ID, casos reais e o que muda em produção.
intermediárioPublicada em · 48 min de leitura
Imagine um painel de pedidos aberto num navegador. Alguém muda o status de um pedido no servidor e a tela precisa mostrar a mudança sem que ninguém aperte F5. O HTTP, o protocolo que o navegador usa para conversar com servidores, não foi desenhado para isso: quem fala é sempre o cliente. Server-Sent Events, ou (eventos enviados pelo servidor), é a forma mais simples de o servidor falar primeiro usando HTTP comum. O navegador faz um pedido, o servidor responde com status 200 e, em vez de encerrar a resposta, passa a escrever mensagens curtas nela, uma depois da outra, enquanto a conexão durar. A API que o navegador usa para ler esse fluxo se chama .
Esta aula segue o caminho de um , do servidor até uma função JavaScript. Primeiro vemos por que o SSE existe e o que ele substitui. Depois olhamos a conversa HTTP crua e o formato exato do texto que o servidor escreve. Em seguida, a API no front, a reconexão automática com , o servidor em Go com um de broadcast, a limitação de headers na autenticação, o que muda em produção e os erros mais comuns. Todos os exemplos foram executados, e as saídas mostradas são capturadas em 2026-10-27 com o Go 1.27.2 e o Node 24.21.0. Quando algo depende de um serviço da AWS ou de um navegador que não rodei, a aula diz isso.
1. o problema: http é pergunta e resposta
O HTTP foi pensado para documentos. O cliente pede, o servidor responde e a troca termina. Nada no protocolo permite que o servidor mande uma mensagem sem que alguém tenha perguntado antes, porque não existe uma conexão esperando por isso. Se o pedido mudou às 14h03, o navegador só descobre se perguntar depois das 14h03. Essa limitação vem do desenho do protocolo: quem inicia a conversa é o cliente, porque é ele que precisa da informação.
A solução mais antiga é o polling. O navegador faz um GET a cada alguns segundos e pergunta se há novidade. Funciona, mas a maior parte das respostas diz que nada mudou. Um painel que pergunta a cada 2 segundos faz 1800 requisições por hora, e a maioria volta vazia. A latência também piora: uma mudança leva até o próximo ciclo para aparecer, então um intervalo de 2 segundos quer dizer até 2 segundos de atraso.
O long polling melhora a latência. O cliente faz o GET e o servidor segura a resposta até ter algo para dizer ou até estourar um tempo limite. Quando responde, o cliente emenda outro pedido. A resposta sai no instante do evento, mas cada evento continua custando uma requisição completa, com cabeçalhos e autenticação de novo, e o servidor passa a manter uma conexão aberta por cliente parado.
O SSE leva a ideia até o fim. Existe um único GET, e o servidor não encerra a resposta. Os eventos vão chegando no mesmo corpo, um após o outro, e os cabeçalhos são pagos uma vez só. A direção é apenas do servidor para o cliente, o que basta para notificações, progresso de um job, feed e dashboards. Além disso, o próprio navegador reconecta quando a conexão cai, e a seção 5 mostra como.
O WebSocket é outro protocolo, e não uma extensão do HTTP. A conexão começa com um pedido HTTP que pede o , e, se o servidor aceita, ela vira um canal TCP em que os dois lados enviam mensagens quando quiserem. Esse é o ganho para chat e jogos, em que o cliente também fala o tempo todo. O preço é uma infraestrutura que entenda o protocolo e a reconexão e a garantia de entrega ficam por conta do seu código.
A figura compara as quatro formas no mesmo cenário. Na linha do polling, a seta rosa se repete a cada 2 segundos e quase todas as respostas voltam sem novidade. Na linha do long polling, há uma única requisição que fica pendurada até o evento. Na linha do SSE, há uma conexão e os pacotes verdes são eventos chegando sem serem pedidos. A linha do WebSocket é a única com setas nos dois sentidos.
| Critério | Polling | Long polling | SSE | WebSocket |
|---|---|---|---|---|
| Quem inicia cada mensagem | Cliente | Cliente | Servidor, depois de um GET inicial | Qualquer lado |
| Direção | Cliente pergunta, servidor responde | Igual ao polling, mas a resposta espera o evento | Só do servidor para o cliente | Nos dois sentidos |
| Requisições para N eventos | N ou mais, a maioria vazia | Uma por evento | Uma para o stream inteiro | Uma conexão, depois do upgrade |
| Protocolo | HTTP comum | HTTP comum | HTTP com | ws:// ou wss://, após o upgrade |
| Reconexão | Seu código | Seu código | O navegador, com Last-Event-ID | Seu código |
| Formato do dado | O que a API devolver | O que a API devolver | Texto UTF-8 | Texto ou binário |
| Passa por proxies e CDNs comuns | Sim | Sim | Sim, com cuidados de buffer e timeout | Depende da infraestrutura |
O SSE tem uma limitação conhecida que pesa em produção. Sem HTTP/2, o navegador aceita um número pequeno de conexões por origem, e o MDN cita seis. A seção 8 trata disso.
2. como funciona por dentro: a conversa crua
Antes de qualquer API, vale ver a conversa. O exemplo desta aula é um servidor em Go que emite um evento tick a cada 2 segundos e uma mensagem com duas linhas quando alguém faz um POST. A captura abaixo foi feita com curl -N -i, em que -N desliga o buffer do curl e -i mostra os cabeçalhos da resposta.
HTTP/1.1 200 OK
Cache-Control: no-cache
Content-Type: text/event-stream; charset=utf-8
X-Accel-Buffering: no
Date: Wed, 28 Oct 2026 00:48:34 GMT
Transfer-Encoding: chunked
retry: 3000
id: 17
event: tick
data: {"n":16}
id: 18
event: mensagem
data: linha A
data: linha B
: ping
id: 19
event: tick
data: {"n":17}
id: 20
event: tick
data: {"n":18}
: ping
id: 21
event: tick
data: {"n":19}
O pedido é um GET comum. O navegador envia o cabeçalho Accept: text/event-stream, e a resposta precisa trazer Content-Type: text/event-stream com status 200. Se qualquer uma dessas condições falhar, o navegador desiste da conexão, como a seção 5 detalha. A resposta usa Transfer-Encoding: chunked, e não tem Content-Length: o servidor não sabe quantos bytes vai mandar, porque não sabe quando o fluxo acaba. É esse detalhe que mantém a resposta aberta.
Os cabeçalhos do servidor do exemplo são estes três, definidos no handler:
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
O X-Accel-Buffering: no é um recado para o Nginx, que a seção 8 explica. Ele não faz nada no navegador.
O corpo chega aos poucos. O navegador lê os bytes à medida que eles aparecem, junta as linhas até encontrar uma linha em branco e, a cada bloco completo, dispara um evento. Essa leitura incremental é o ponto central da técnica, e também é a fonte da maioria dos bugs. Se o servidor escreve no corpo e não manda os bytes para a rede, eles ficam num buffer intermediário e o navegador não recebe nada. Em Go, isso se resolve com Flush, e a seção 6 mostra como.
A figura usa um canal como metáfora para a conexão aberta. O servidor empurra bytes de um lado e o navegador lê do outro, e a resposta nunca recebe o marcador final que um Content-Length daria. O , que é um comentário sem dado algum, entra pelo lado de baixo: ele não chega ao navegador como evento, mas mantém o tubo com movimento para que intermediários não o considerem ocioso. A seção 8 explica por que isso importa.
3. o formato do protocolo
O SSE usa texto puro codificado em UTF-8. O formato é definido na seção Server-sent events da especificação WHATWG do HTML, e cabe em poucas regras. Cada linha tem um campo e um valor separados por dois-pontos, como data: {"n":1}. Um evento termina com uma linha em branco. Linhas podem terminar em CRLF, LF ou CR, e um BOM UTF-8 no começo do fluxo é removido.
Os campos reconhecidos são quatro. O campo data leva o conteúdo do evento. Se houver várias linhas data seguidas, o navegador as junta com um caractere de nova linha entre elas, e não as concatena direto. O campo event dá o nome do tipo de evento, e quando ele falta o tipo é message, que é o evento que onmessage recebe. O campo id define o último ID de evento, que o navegador guarda e envia de volta na reconexão. O campo retry diz quantos milissegundos o navegador espera antes de reconectar, e só aceita dígitos, do contrário é ignorado. Qualquer outro nome de campo é ignorado.
A linha que começa com dois-pontos é um comentário. Ela não gera evento e não tem valor para a aplicação, e serve como heartbeat: o servidor manda : ping de tempos em tempos para manter a conexão viva. A especificação diz que a linha é simplesmente ignorada, e é por isso que o heartbeat é seguro.
A captura da seção 2 traz um caso que vale olhar de perto. O POST enviou o corpo linha A seguido de linha B, e o servidor o transformou em dois campos data dentro de um único evento:
id: 18
event: mensagem
data: linha A
data: linha B
O navegador entrega isso como um único evento de tipo mensagem, com data igual a linha A\nlinha B. Uma consequência prática: se o seu dado é JSON, ele não pode ter quebras de linha fora de strings, ou então você precisa serializar sem quebras. O exemplo do servidor junta as linhas assim de propósito, para mostrar o comportamento.
Dois detalhes da especificação pesam no código. O primeiro é que um evento sem nenhuma linha data não é disparado. Um bloco que só tem id ou retry atualiza o estado do navegador, mas não gera MessageEvent. O segundo é que, se o fluxo termina antes da linha em branco, o evento incompleto é descartado. Por isso o servidor precisa sempre terminar cada evento com a linha em branco, mesmo quando faz logo depois.
A figura separa os dois lados do parser. À esquerda estão os bytes crus, que o navegador lê linha por linha. No centro, o parser reconhece cada campo e o guarda onde ele deve ficar: o id vira o lastEventId, o event vira o tipo e o data vira o conteúdo do MessageEvent. O retry não chega ao evento, porque ele configura a conexão. O comentário também não chega, porque é descartado. Observe que só os três campos que aparecem na caixa azul fazem parte do evento entregue à aplicação.
4. a api EventSource no front
Do lado do navegador, a API é uma classe chamada EventSource. O construtor recebe a URL e um objeto opcional com withCredentials, que é false por padrão e controla se cookies e credenciais de CORS vão junto com a requisição. A propriedade url devolve o endereço. A propriedade readyState mostra em que ponto está a conexão, e tem três valores: CONNECTING (0), quando está tentando abrir ou reconectar, OPEN (1), quando recebe eventos, e CLOSED (2), quando a conexão foi encerrada de vez e não volta mais.
Os três eventos principais são open, quando a resposta chega com status 200 e content-type correto, message, para eventos sem campo event, e error, que dispara em qualquer falha, inclusive as que o navegador vai contornar reconectando. Para eventos nomeados, existe addEventListener com o nome do tipo, e é assim que se trata um evento tick ou pedido-atualizado. Cada evento chega como um MessageEvent, cujos campos úteis são data (sempre string), lastEventId (o último id recebido), origin (a origem do servidor) e type (o nome do evento). Para fechar a conexão, chama-se close(). Ele muda readyState para CLOSED e nenhum evento novo é disparado depois disso.
O exemplo mais simples cabe em poucas linhas, e foi compilado com tsc --strict contra os tipos DOM:
const es = new EventSource('/eventos');
es.onopen = () => {
console.log('conectado, readyState =', es.readyState);
};
es.addEventListener('tick', (evento: MessageEvent<string>) => {
const { n } = JSON.parse(evento.data) as { n: number };
console.log('tick', n, 'id', evento.lastEventId);
});
es.onerror = () => {
if (es.readyState === EventSource.CLOSED) {
console.log('falha definitiva, o navegador não vai reconectar');
} else {
console.log('queda momentânea, reconectando');
}
};
// Quando a tela não precisa mais do fluxo:
es.close();
A figura seguinte mostra a máquina de estados que o readyState percorre. Repare que CONNECTING aparece duas vezes: na abertura e depois de cada queda que o navegador contorna. O estado final CLOSED só é alcançado por close() ou por uma falha que a especificação considera definitiva.
Na figura, a seta entre CONNECTING e OPEN é a conexão bem-sucedida, com 200 e text/event-stream. A seta de volta, marcada como queda, é o que o navegador faz sozinho: ele dispara error com readyState em CONNECTING e espera o tempo do retry antes de tentar outra vez. As duas setas que chegam a CLOSED são as que nunca voltam. Uma delas é o close() do seu código, e a outra é uma resposta que a especificação trata como falha, cujos casos a seção 5 descreve com precisão.
Em React, o problema é outro: a conexão precisa existir enquanto o componente estiver montado, e precisa ser fechada quando ele sai da tela. O hook abaixo cuida disso. O efeito cria o EventSource, registra os listeners e devolve uma função de cleanup que chama close(). Com StrictMode em desenvolvimento, o React monta, desmonta e monta de novo o componente para expor efeitos sem cleanup, e o cleanup é o que impede a conexão de ficar duplicada. O parse de JSON passa por parseJsonSeguro, que devolve null em vez de lançar erro quando o dado não é JSON. Isso não valida a forma do objeto. Para validar, use um schema como o zod.
import { useEffect, useState } from 'react';
export type StatusConexao = 'conectando' | 'aberta' | 'reconectando' | 'fechada';
// JSON.parse lança erro com texto que não é JSON. Devolvemos null em vez de derrubar a tela.
// Isto não valida o formato: para isso, use um schema (zod, por exemplo).
export function parseJsonSeguro<T>(texto: string): T | null {
try {
return JSON.parse(texto) as T;
} catch {
return null;
}
}
export function useEventSource<T>(url: string | null, nomeEvento = 'message') {
const [ultimo, setUltimo] = useState<T | null>(null);
const [status, setStatus] = useState<StatusConexao>('conectando');
useEffect(() => {
if (url === null) return;
const es = new EventSource(url);
setStatus('conectando');
const aoAbrir = () => setStatus('aberta');
const aoErro = () => {
// CONNECTING: o navegador vai reconectar sozinho. CLOSED: falha definitiva.
setStatus(es.readyState === EventSource.CLOSED ? 'fechada' : 'reconectando');
};
const aoReceber = (e: MessageEvent<string>) => {
const valor = parseJsonSeguro<T>(e.data);
if (valor !== null) setUltimo(valor);
};
es.addEventListener('open', aoAbrir);
es.addEventListener('error', aoErro);
es.addEventListener(nomeEvento, aoReceber as EventListener);
// Cleanup: fecha a conexão. Depois do close() nenhum evento deste objeto é disparado.
return () => es.close();
}, [url, nomeEvento]);
return { ultimo, status };
}
Para testar o hook fora do navegador, usei o EventSource experimental do Node 24, habilitado com --experimental-eventsource, e um componente montado em jsdom, apontado para o servidor Go desta aula. Na execução original desta aula, o componente recebeu os ticks do servidor, e parseJsonSeguro devolveu null para um texto que não é JSON. Esse resultado não foi reproduzido na revisão.
No StrictMode de desenvolvimento, o React executa o efeito, desfaz e executa de novo. Por isso o cleanup do hook precisa fechar a instância criada na primeira execução, e o trecho acima está escrito para que isso funcione. Não reexecutei nesta revisão a contagem de chamadas a close() que eu havia registrado, porque ela tinha valores que não consegui explicar.
O mesmo cliente funciona em Node para conferir o protocolo fora do navegador. O teste abaixo conecta no servidor Go, depois simula uma queda e uma recusa. A saída é a real:
## 1 fluxo feliz (servidor Go)
inicial readyState=0 url=http://localhost:8081/eventos withCredentials=false
onopen readyState=1
tick id=73 data={"n":71} origin=http://localhost:8081 type=tick
tick id=74 data={"n":72} origin=http://localhost:8081 type=tick
tick id=75 data={"n":73} origin=http://localhost:8081 type=tick
close() -> readyState=2
## 2 queda e reconexao
conexao 1: last-event-id=(ausente)
conexao 2: last-event-id=7
onopen readyState=1
message data=antes da queda lastEventId=7
onerror readyState=0
onopen readyState=1
message data=depois da reconexao lastEventId=8
## 3a resposta 204
204: inicial readyState=0
onerror readyState=2
apos 4s: readyState=2, requisicoes recebidas=1
## 3b content-type text/plain
text/plain: inicial readyState=0
onerror readyState=2
apos 4s: readyState=2, requisicoes recebidas=1
Esta saída mostra três comportamentos. No fluxo feliz, readyState passa de 0 para 1 e o close() leva a 2. Na queda, o cliente reconecta sozinho e envia last-event-id=7 no segundo pedido, e o error aparece com readyState=0 antes da reconexão. Nas respostas 204 e text/plain, o cliente não reconecta: fica em CLOSED depois de uma única requisição. A próxima seção explica por quê.
5. reconexão e Last-Event-ID
Quando a conexão cai por um motivo de rede, o navegador não avisa a aplicação e vai embora. Ele reconecta sozinho. A especificação diz que, na queda, o agente põe o estado em CONNECTING, dispara o evento error, espera o tempo de reconexão e então refaz o pedido. O valor inicial do tempo de reconexão é definido pela implementação, e a especificação fala em uma faixa de poucos segundos. O campo retry do servidor troca esse valor, em milissegundos, e vale enquanto a conexão durar.
Na nova conexão, o navegador envia o cabeçalho Last-Event-ID com o último id que recebeu, desde que ele não seja vazio. É esse cabeçalho que permite ao servidor saber o que o cliente perdeu. A especificação não define o que o servidor faz com ele. Essa parte é código seu.
O servidor do exemplo guarda os eventos recentes num de 16 posições. Quando um pedido chega com Last-Event-ID, o hub devolve os eventos guardados cujo ID é maior que o informado e, a partir daí, continua mandando os eventos ao vivo. O registro do cliente e a cópia do buffer acontecem dentro da mesma goroutine do hub, então nenhum evento fica de fora entre um e outro. A captura abaixo foi feita com Last-Event-ID: 3 contra o servidor que já tinha emitido até o evento 21.
HTTP/1.1 200 OK
Cache-Control: no-cache
Content-Type: text/event-stream; charset=utf-8
X-Accel-Buffering: no
Date: Wed, 28 Oct 2026 00:48:42 GMT
Transfer-Encoding: chunked
retry: 3000
id: 6
event: tick
data: {"n":5}
id: 7
event: tick
data: {"n":6}
id: 8
event: tick
data: {"n":7}
id: 9
event: tick
data: {"n":8}
id: 10
event: tick
data: {"n":9}
id: 11
event: tick
data: {"n":10}
id: 12
event: tick
data: {"n":11}
id: 13
event: tick
data: {"n":12}
id: 14
event: tick
data: {"n":13}
id: 15
event: tick
data: {"n":14}
id: 16
event: tick
data: {"n":15}
id: 17
event: tick
data: {"n":16}
id: 18
event: mensagem
data: linha A
data: linha B
id: 19
event: tick
data: {"n":17}
id: 20
event: tick
data: {"n":18}
id: 21
event: tick
data: {"n":19}
id: 22
event: tick
data: {"n":20}
: ping
id: 23
event: tick
data: {"n":21}
O replay começa no ID 6, e não no 4. Os IDs 4 e 5 já tinham saído do buffer de 16 posições, então o servidor não tinha mais como reenviá-los. Esse é o limite central do modelo, e não um detalhe do exemplo: o SSE não garante entrega. O buffer decide até onde o cliente consegue voltar. Se a queda durar mais que o buffer, a lacuna é permanente, e a aplicação precisa de uma forma de buscar o estado atual, como uma consulta REST, para fechar o buraco. Deduplicação também fica por sua conta: se o servidor reenviar um evento que o cliente já processou, o cliente precisa ignorá-lo pelo ID.
A sequência mostra a mesma história na ordem do tempo. O navegador pede o stream, recebe os eventos 1 a 3, perde a conexão e refaz o pedido com Last-Event-ID: 3. O servidor consulta o buffer. Nesta captura, o buffer de 16 posições já tinha descartado os IDs 4 e 5, então o replay começa no 6, e o salto fica visível no desenho. Nenhum cabeçalho cobre esse salto: a lacuna só se fecha buscando o estado atual por outro caminho, como uma consulta REST.
Agora a parte que muita gente erra. O navegador para de reconectar em algumas situações, e a especificação é precisa sobre elas. Se a resposta não tem status 200, ou se o Content-Type não é text/event-stream, o agente falha a conexão, e a especificação diz que, depois de falhar, ele não tenta reconectar. Uma resposta 204 cai no mesmo caso, porque não é 200. É assim que um servidor encerra o fluxo de forma definitiva: responde 204, e o cliente não volta. O teste em Node da seção 4 confirmou isso para 204 e para text/plain, nos dois casos com uma única requisição e readyState em 2.
Um erro de servidor 5xx também não é reconexão. Pela mesma regra da especificação, um status diferente de 200 é falha, e o navegador não tenta de novo. Por isso o loop de reconexão que costuma aparecer em produção raramente vem de um 5xx. Ele costuma vir de uma resposta 200, com o content-type certo, que termina logo depois de começar, porque um proxy ou um servidor derrubou a conexão. Aí o navegador enxerga uma queda normal, espera o retry e refaz o pedido, em ciclo.
6. o lado do servidor em go
Um handler de SSE em Go faz quatro coisas, e cada uma tem um motivo. Ele define os cabeçalhos antes de escrever qualquer byte, porque depois do primeiro Write o net/http envia o status e os cabeçalhos e eles não mudam mais. Ele faz flush a cada evento, para que os bytes saiam para a rede. Ele observa r.Context().Done(), que é fechado quando o cliente sai, para não manter uma goroutine presa a uma conexão morta. E ele manda um heartbeat com um time.Ticker, para que a conexão não pareça ociosa para os intermediários.
O flush é feito com http.ResponseController, introduzido no Go 1.20. A documentação do pacote diz que Flush envia para o cliente qualquer dado guardado no buffer. Antes dele, a forma era testar se o ResponseWriter implementa a interface http.Flusher com uma asserção de tipo. A diferença importa com middlewares: um wrapper que envolve o ResponseWriter pode não implementar Flusher, e a documentação do Flusher avisa exatamente isso. O ResponseController resolve a questão porque procura o Flush pela cadeia de wrappers que expõem Unwrap. Se você escreve um middleware de log ou de compressão, esse é o ponto que costuma quebrar o stream sem nenhum erro aparente. A aula sobre Middlewares em Go mostra como um wrapper de ResponseWriter é montado.
O handler tem um detalhe que quase sempre é esquecido. O http.Server com WriteTimeout diferente de zero encerra qualquer resposta que passe desse tempo, e um stream de SSE nunca termina. A documentação diz que o timeout de escrita é reiniciado a cada nova requisição, mas não protege um fluxo longo. Por isso o exemplo deixa WriteTimeout zerado e confia nos heartbeats e no r.Context().
Abaixo está o núcleo do hub. Ele é o único dono dos clientes e do buffer, e quem quer falar com ele manda uma mensagem por canal. Com isso não há mutex, e a regra de concorrência fica explícita no código.
func (h *Hub) Run(ctx context.Context) {
defer close(h.feito)
for {
select {
case <-ctx.Done():
for ch := range h.clientes {
close(ch)
}
return
case in := <-h.inscrever:
ch := make(chan Evento, 8)
h.clientes[ch] = struct{}{}
var replay []Evento
if in.replayar {
replay = h.buf.depois(in.ultimoID)
}
in.resposta <- resultado{replay: replay, ch: ch}
case ch := <-h.desinscrever:
if _, ok := h.clientes[ch]; ok {
delete(h.clientes, ch)
close(ch)
}
case e := <-h.publicar:
h.ultimoID++
e.ID = h.ultimoID
h.buf.adiciona(e)
for ch := range h.clientes {
select {
case ch <- e:
default:
delete(h.clientes, ch)
close(ch)
}
}
}
}
}
O select com default no envio é a decisão de produção mais importante do hub. Cada cliente tem um canal com capacidade 8. Se o canal está cheio, o cliente não está lendo rápido o bastante, e o hub não pode esperar por ele, porque isso travaria a distribuição para todos. A saída escolhida é desconectar o cliente lento: o hub fecha o canal, o handler percebe o canal fechado e encerra a resposta, e o navegador reconecta com Last-Event-ID, recebendo do buffer o que perdeu. A alternativa seria descartar só o evento do cliente lento, o que deixaria uma lacuna que ele nunca saberia que existe. A desconexão força a recuperação pelo caminho que já existe.
A figura mostra esse mecanismo por dentro: o gerador publica, o hub distribui para um canal por cliente, e o canal do cliente B está cheio.
Na figura, o hub é a goroutine que pulsa, porque é o único dono do estado. Os canais têm buffer de 8, e o do cliente A está quase vazio, com o handler escrevendo e fazendo flush rapidamente. O canal do cliente B está cheio e marcado com erro. A seta bloqueada com X mostra o momento em que o hub decide desconectar. Depois disso, o handler B sai ao ver o canal fechado, e o navegador lento reconecta sozinho.
O hub também precisa de uma saída limpa. Quando o contexto é cancelado, Run fecha os canais de todos os clientes e os handlers encerram suas respostas. Isso se combina com o Shutdown do servidor, cuja documentação diz que ele fecha os listeners e espera as conexões ativas voltarem a ocioso, sem limite de tempo por padrão. O exemplo usa um timeout de 5 segundos no Shutdown, porque as conexões de SSE nunca ficam ociosas sozinhas. A aula sobre Graceful Shutdown e Signal Handling trata esse padrão com mais calma.
Os testes do hub cobrem o buffer circular, o replay pelo ID, a ausência de replay para um cliente novo e a desconexão do cliente lento. Eles rodam com o detector de corrida do Go:
=== RUN TestBufferCircular
--- PASS: TestBufferCircular (0.00s)
=== RUN TestReplayPeloLastEventID
--- PASS: TestReplayPeloLastEventID (0.00s)
=== RUN TestSemLastEventIDNaoReplaya
--- PASS: TestSemLastEventIDNaoReplaya (0.00s)
=== RUN TestClienteLentoEDesconectado
--- PASS: TestClienteLentoEDesconectado (0.00s)
PASS
ok aulasse 1.233s
Um teste escrito antes da correção mostrou um detalhe que vale guardar. Publicar devolve assim que o hub recebe o evento, e não quando a distribuição termina. Um teste que começa a ler o canal logo depois de publicar pode drenar espaço antes do nono evento e nunca ver o overflow. A correção foi uma inscrição extra que só responde depois de o hub processar tudo, porque o hub atende um pedido por vez. Esse é o tipo de fato que a documentação não lista e que só aparece quando alguém mede.
O código completo do servidor e dos testes está no anexo, no fim da aula.
7. autenticação e a limitação de headers
O construtor do EventSource recebe a URL e as opções de credenciais, e nada mais. Ele não aceita headers, e o método é sempre GET. Isso significa que o Authorization: Bearer que sua API usa em todas as outras chamadas não tem como ir junto. Essa é a limitação que mais surpreende quem vem de fetch, e ela decide a arquitetura de autenticação.
A primeira alternativa é o cookie de sessão. O navegador envia o cookie automaticamente quando a origem bate, e com withCredentials: true ele vai também em requisições cross-origin. Só que o CORS fica mais rígido: o servidor precisa responder com Access-Control-Allow-Origin apontando para a origem exata, sem *, e com Access-Control-Allow-Credentials: true. Se a origem da página for diferente do servidor, esquecer uma dessas coisas faz o navegador bloquear a resposta. Em mesma origem, o cookie basta e o problema some. O trade-off é que o cookie exige cuidado com CSRF e com a configuração do domínio.
A segunda alternativa é colocar o token na query string, como /eventos?token=.... Ela funciona com qualquer cliente e não depende de cookie, e é a mais fácil de implementar. O risco é que a URL inteira vai para logs de acesso do servidor, do proxy e do CDN, aparece no histórico do navegador e pode vazar por qualquer ferramenta que guarde URLs. Um token de longa duração na query é um vazamento esperando acontecer. Se você escolher essa via, o token deve ser de curta duração e de escopo restrito.
A terceira alternativa é um ticket. O cliente autenticado faz um POST comum, pedindo um ticket, e o servidor devolve um valor aleatório de vida curta, por exemplo 30 segundos, que vale uma única vez. O EventSource abre a conexão com esse ticket na URL, e o servidor o invalida no primeiro uso. Assim o token de sessão nunca aparece na URL, e um ticket vazado vale pouco. O custo é um endpoint a mais e um armazenamento para os tickets, que precisa ser compartilhado se você tiver várias instâncias.
A quarta alternativa é abandonar o EventSource. Com fetch e ReadableStream, você lê o corpo da resposta na mão, o que permite POST, headers customizados e até um token no Authorization. Há também a biblioteca @microsoft/fetch-event-source, que faz esse trabalho e cuida de reconexão. Não verifiquei a documentação dela nesta sessão, então não descrevo a API. A troca tem um custo: a reconexão automática do navegador deixa de existir, e você passa a implementá-la, inclusive o envio do Last-Event-ID.
Na prática, a escolha costuma ser entre cookie e ticket. O cookie é a menos trabalhosa quando o front e a API ficam na mesma origem. O ticket é a melhor opção quando a API está em outro domínio e você não quer depender de CORS com credenciais. A query com token fixo é a que você deve evitar.
8. produção: buffer, timeouts e escala
Em desenvolvimento, o SSE funciona com um servidor direto. Em produção, há uma cadeia de intermediários, e cada um tem uma configuração própria que pode matar o stream sem nenhum erro óbvio.
O primeiro risco é o buffer. O Nginx liga o buffer das respostas de proxy por padrão. A documentação de proxy_buffering diz que o padrão é on e que o buffer também pode ser ligado ou desligado pelo cabeçalho X-Accel-Buffering da resposta do upstream, com yes ou no. Por isso o servidor do exemplo manda X-Accel-Buffering: no, e a configuração segura é proxy_buffering off no bloco da rota de eventos. Sem isso, o Nginx segura os bytes até juntar um bloco grande, e o usuário vê os eventos chegarem em lotes, ou não vê nada. A compressão de resposta é um problema parecido: um gzip que espera um bloco para compactar atrasa os eventos da mesma forma. Não verifiquei nesta sessão a configuração de compressão de cada proxy, então a recomendação é desligar a compressão na rota de eventos e confirmar com um teste real.
O segundo risco são os timeouts de ociosidade, e aqui os números importam. O Application Load Balancer da AWS tem idle timeout padrão de 60 segundos, que é o tempo de inatividade antes de ele fechar a conexão. O CloudFront tem o origin response timeout, que a documentação define como o tempo que ele espera pela resposta do origin e, depois de receber um pacote, o tempo até o próximo pacote. O padrão é 30 segundos. O Nginx tem proxy_read_timeout com padrão de 60 segundos, que mede o intervalo entre duas leituras consecutivas do upstream. Nos três casos, um stream que fica em silêncio por mais que o limite é derrubado. É por isso que o heartbeat existe. Um : ping a cada 15 segundos, como no exemplo, mantém o intervalo entre pacotes abaixo de 30 e de 60 segundos, e mantém a conexão com movimento do ponto de vista de qualquer intermediário.
Há ainda um timeout opcional no CloudFront, o response completion timeout, que limita o tempo total de uma resposta. Ele não vem ativado por padrão, mas se você configurar um limite, o stream é encerrado ao final desse tempo mesmo que os pacotes continuem chegando. Por isso o cliente tem de estar preparado para reconectar em qualquer momento, o que já é o caso no navegador.
Sobre o CloudFront, há um cuidado que não está nos timeouts. A distribuição não pode guardar em cache a resposta de um stream, porque um evento guardado seria servido a todos os usuários como se fosse atual. A rota de eventos precisa de uma política de cache que não armazena a resposta. Não verifiquei nesta sessão o comportamento específico do CloudFront com SSE além dos timeouts, então isso é uma regra de cautela e não uma citação. A aula sobre Amazon CloudFront cobre a configuração da distribuição.
O terceiro ponto é o limite de conexões. A seção de SSE do MDN diz que, sem HTTP/2, o navegador tem um limite de seis conexões por navegador e domínio, e que esse limite vale somado para todas as abas abertas no mesmo domínio. O MDN também diz que Chrome e Firefox marcaram o problema como “won’t fix”. Uma pessoa com três abas do painel, cada uma com dois streams, já esgota as seis conexões, e o sétimo stream fica esperando sem erro visível. Com HTTP/2, a situação muda: a especificação negocia o número de streams simultâneos, e o MDN cita 100 como padrão, todos multiplexados numa única conexão TCP.
A figura põe os dois cenários lado a lado. À esquerda, cada aba abre dois streams, e as seis conexões se esgotam com as três primeiras abas. A quarta aba fica com o X vermelho, esperando uma vaga que só surge quando alguma aba fechar. À direita, as quatro abas usam o mesmo servidor HTTP/2 sem disputar conexões, porque os streams viajam juntos numa única conexão. A diferença não está no código, está no protocolo que a infraestrutura usa.
O quarto ponto é a escala horizontal. Um cliente conectado na instância A não recebe um evento que nasceu na instância B, porque o hub de cada processo só conhece os próprios clientes. A solução usual é um canal de : quando um evento nasce, a instância o publica num broker, e todas as instâncias recebem e distribuem para os seus clientes. A figura mostra o Redis em modo pub/sub nesse papel. O ponto a conhecer é que o pub/sub do Redis não guarda histórico: se uma instância estiver fora no momento da publicação, ela não recebe o evento, e o buffer de Last-Event-ID de cada instância só cobre o que ela mesma viu. Não verifiquei nesta sessão as garantias de entrega do Redis, então trate essa descrição como o modelo geral. Outras opções são NATS e Kafka, que oferecem histórico com nuances diferentes. A escolha depende de quanto da lacuna você aceita e de quanto custa fechá-la.
A figura de produção mostra o caminho inteiro de um GET de SSE. O navegador chega ao CloudFront, passa pelo ALB e pelo Nginx e cai numa das instâncias Go. Os selos de aviso marcam o que pode matar o stream, e o Nginx está em rosa porque o buffer é o risco mais traiçoeiro: ele não gera erro, só atrasa. As setas verdes entre a instância A e o Redis mostram o fan-out, em que um evento publicado numa instância chega a todas as outras. A figura não mostra todos os caminhos de falha, e a seção de erros cobre os que faltam.
O último ponto é o recurso. Cada conexão aberta ocupa um descritor de arquivo e memória para os buffers e para a goroutine do handler. Um servidor com dezenas de milhares de streams precisa de limite de descritores, monitoramento de conexões abertas e uma política de desconexão para clientes que não leem. Esses números dependem do sistema operacional e do número de conexões, e não medi nenhum deles nesta aula.
No deploy, o efeito é direto. Cada novo rollout derruba todos os streams da instância antiga, e todos os clientes reconectam ao mesmo tempo. Isso é aceitável, porque a reconexão é automática e o buffer recupera a maior parte, mas um deploy que reinicia tudo de uma vez gera um pico de reconexões. A saída é rolar as instâncias aos poucos, com o Shutdown do servidor e um intervalo entre elas.
9. casos de uso reais
As seções anteriores usaram um servidor de exemplo com um relógio. Esta seção olha para os casos em que o SSE aparece de verdade, com o problema que cada um resolve, por que o SSE encaixa ou não, e o formato dos eventos. Dois casos trazem payload e código. Os outros são descritos como ilustrativos quando eu não pude conferir o comportamento de um produto específico.
streaming de tokens de um LLM
O problema é de espera. Uma resposta longa de um modelo pode levar dezenas de segundos para terminar, e um usuário que olha uma tela em branco por esse tempo acha que o sistema travou. O streaming resolve isso mostrando cada pedaço de texto assim que ele é gerado, o que dá a sensação de resposta quase imediata sem mudar o tempo total.
A documentação de streaming da Anthropic diz que, ao criar uma mensagem, é possível definir "stream": true para receber a resposta de forma incremental, usando eventos SSE. A mesma página descreve a sequência de eventos: um message_start com o objeto da mensagem vazio em content, depois, para cada bloco de conteúdo, um content_block_start, um ou mais content_block_delta e um content_block_stop, em seguida um ou mais message_delta e, por fim, um message_stop. Entre eles podem aparecer eventos ping, e erros também podem chegar dentro do stream, como um overloaded_error que, sem streaming, viria como HTTP 529. A página avisa que novos tipos de evento podem surgir e que o código deve tratar tipos desconhecidos sem quebrar.
Este é o payload de um pedaço de texto, copiado da documentação:
event: content_block_delta
data: {"type": "content_block_delta","index": 0,"delta": {"type": "text_delta", "text": "ello frien"}}
O formato é exatamente o da seção 3: um campo event com o nome, um campo data com JSON, e a linha em branco no fim. O que muda é o conteúdo, que é um objeto com o índice do bloco e o pedaço de texto. Um detalhe da mesma página vale notar: os usage dos eventos message_delta são acumulados, não incrementais, então o último valor é o total.
Aqui o SSE encaixa porque o fluxo é unidirecional: o modelo gera e o cliente só consome. Existe um obstáculo prático, porém. A criação de uma mensagem é um pedido que carrega o prompt e a chave de API no corpo e nos cabeçalhos, e o EventSource só faz GET sem corpo. Por isso o desenho comum é o navegador chamar o seu backend com EventSource, e o backend, que guarda a chave, fazer a chamada ao modelo com stream: true e repassar os eventos. O backend pode repassar o formato da Anthropic tal como veio ou convertê-lo para eventos próprios, como token e fim. Esse desenho é ilustrativo: os nomes /api/chat/eventos e a conversão não vêm de nenhuma fonte, são decisões de arquitetura.
Um handler que repassa os deltas, ilustrativo e sem chamada real ao modelo, mostra a forma do lado do servidor. Ele usa o escreve da seção 6, mas não passa pelo hub: o stream pertence a uma única resposta, e o texto de um usuário não pode chegar a outro. Os IDs saem em sequência dentro da resposta, para que a reconexão tenha um Last-Event-ID válido:
// ilustrativo: deltas representa a leitura do stream do modelo no seu backend
func repassaTokens(w http.ResponseWriter, deltas <-chan string) {
rc := http.NewResponseController(w)
var id int64
for texto := range deltas {
id++
payload, _ := json.Marshal(map[string]any{"type": "content_block_delta", "index": 0,
"delta": map[string]string{"type": "text_delta", "text": texto}})
if err := escreve(w, Evento{ID: id, Tipo: "content_block_delta", Dados: string(payload)}); err != nil {
return
}
if err := rc.Flush(); err != nil {
return
}
}
}
progresso de upload ou de job longo
O problema é o usuário que envia um CSV de 200 mil linhas ou um vídeo de vários gigabytes e não sabe se o sistema está fazendo alguma coisa. O cliente conhece o percentual do próprio upload, mas o processamento que acontece depois, como importar linhas ou transcodificar um vídeo, só existe no servidor.
O SSE encaixa porque o progresso é uma sequência de números que o servidor produz sozinho, sem o cliente precisar pedir. Um evento de progresso por job, com o percentual, é pequeno e pode ser enviado com frequência. Um detalhe de design é publicar a cada 1% e não a cada linha, porque um evento por linha de 200 mil linhas inunda o cliente à toa. O código abaixo publica o percentual a cada 1% de linhas e um evento final com 100. Ele usa o Hub e o Publicar da seção 6 e foi compilado com go build junto com o servidor desta aula:
func (h *Hub) acompanhaImportacao(job string, total int, linhas <-chan struct{}) {
feito := 0
for range linhas {
feito++
if feito%(total/100) == 0 {
h.Publicar("progresso", fmt.Sprintf(`{"job":%q,"percentual":%d}`, job, feito*100/total))
}
}
h.Publicar("progresso", fmt.Sprintf(`{"job":%q,"percentual":100}`, job))
}
O payload que o navegador recebe é este, e o formato é o da seção 3. O JSON é um formato que eu defini para o exemplo, e não uma convenção de nenhum produto:
event: progresso
id: 42
data: {"job":"importacao-7f3a","percentual":37}
Um ponto que a seção 5 trata e que aqui pesa mais: se o usuário recarregar a página no meio da importação, ela precisa voltar ao percentual atual. O replay pelo Last-Event-ID devolve o que ainda está no buffer, mas um job de vinte minutos pode gerar muitos eventos e o buffer pode já ter descartado o início. Para esse caso, a aplicação costuma consultar o estado atual do job por uma rota REST e depois abrir o stream a partir dali. Essa combinação é a forma mais segura de mostrar progresso, e é ilustrativa, não uma regra.
notificações e feed num painel
O problema é o painel que precisa refletir mudanças feitas por outras pessoas, como um pedido novo chegando na fila de atendimento ou um alerta de métrica. Sem tempo real, cada pessoa atualiza a página na mão ou espera um ciclo de polling, e a informação fica defasada.
O SSE encaixa porque as notificações são uma mão só: o servidor sabe quando algo aconteceu e o cliente só mostra. O volume costuma ser baixo por usuário, e o Last-Event-ID cobre a lacuna curta de uma queda de Wi-Fi. A decisão a conferir é o volume agregado: um painel com milhares de usuários, cada um com uma conexão, precisa do fan-out da seção 8, com um broker entre as instâncias.
Este caso é ilustrativo quanto ao formato. Um evento de notificação poderia ser assim, mas o nome e os campos são de um exemplo, não de um produto:
event: alerta
id: 880
data: {"tipo":"fila","fila":"atendimento","pendentes":34}
log ao vivo de um build ou deploy
O problema é o log que cresce enquanto o processo roda. Quem acompanha um deploy quer ver as linhas aparecendo, e quem abre a página depois quer ver o que já aconteceu e continuar a partir dali. Um terminal web, a página de execução de um pipeline de CI e um tail -f fazem a mesma coisa do ponto de vista do navegador.
O SSE encaixa bem porque o log é texto, cresce num sentido só e cada linha é um evento. O Last-Event-ID com o número da linha permite retomar sem repetir. Não verifiquei como ferramentas específicas de CI implementam essa tela, então não descrevo nenhuma delas. O que se pode afirmar é o encaixe técnico: o log de uma execução é uma sequência de linhas com número, e cada linha pode ser um evento com id. Um exemplo ilustrativo:
event: linha
id: 1204
data: Step 3/7: npm test
Como cada linha é um evento, um log com várias linhas em data ou com quebras de linha dentro do texto precisa ser tratado com cuidado, porque a quebra vira outro campo data. Esse é o comportamento da seção 3, e é o motivo de muitos logs serem enviados um evento por linha.
atualização de pedido ou pagamento
O problema é a tela de um pedido que espera uma confirmação de pagamento. O pagamento pode levar alguns segundos, e o usuário quer ver a mudança de “aguardando” para “pago” sem recarregar. E o caso que mais pede atenção: se a pessoa fechar a aba ou perder a conexão e voltar, ela não pode perder a transição que aconteceu enquanto estava fora.
O SSE encaixa porque o fluxo é do servidor para o cliente, e a transição de status é um evento que o servidor produz quando o webhook do provedor de pagamento chega. A garantia que importa aqui é o Last-Event-ID: o id de cada transição, guardado no buffer, permite reenviar o que a pessoa perdeu na reconexão. Como o buffer é limitado, o estado atual também precisa ser consultado. Para pagamento, a regra de negócio não deve depender só do stream, porque uma transição perdida além do buffer é uma informação faltando. A tela deve buscar o status por uma rota REST ao abrir e usar o stream para as mudanças seguintes.
Um payload ilustrativo de transição de status, com o formato que eu defini para o exemplo:
event: pedido
id: 1093
data: {"pedido":"p-5521","status":"pago","atualizado_em":"2026-10-27T14:03:11Z"}
O hook abaixo usa o da seção 4 e foi compilado com tsc --strict junto com ele. Ele recebe as transições do pedido e devolve o último estado para a tela decidir como mostrá-lo. A reconexão e o Last-Event-ID ficam a cargo do navegador e do servidor, e a consulta REST inicial que mencionei acima não está no trecho:
type PedidoEvento = { pedido: string; status: string; atualizado_em: string };
export function usePedido(id: string) {
return useEventSource<PedidoEvento>(`/api/pedidos/${id}/eventos`, 'pedido');
}
10. sse, websocket e polling: quando usar cada um
A decisão começa pela direção. Se o fluxo vai só do servidor para o cliente, SSE costuma ser a escolha mais simples, porque usa HTTP comum, reconecta sozinho e passa pela infraestrutura existente. Se o cliente também precisa mandar mensagens com frequência e com baixa latência, WebSocket é o caminho. E o polling continua valendo quando a atualização pode esperar alguns segundos e o volume é baixo, porque não exige nenhuma infraestrutura especial.
| Caso | Escolha | Por quê |
|---|---|---|
| Notificações de eventos do sistema | SSE | Fluxo unidirecional, reconexão automática e pouco custo por usuário |
| Progresso de um job longo | SSE | O servidor empurra o percentual, e o cliente só observa |
| Feed de atualizações | SSE | Novidades chegam do servidor, e o cliente não precisa responder a cada item |
| Dashboard com métricas | SSE | Leitura contínua, com reconexão e buffer para lacunas curtas |
| Streaming de tokens de um LLM | SSE | A resposta é uma sequência de texto do servidor, como em system design perguntas complexas |
| Chat multiusuário | WebSocket | Cada participante envia e recebe o tempo todo |
| Jogo multiplayer | WebSocket | Entrada do jogador e estado do mundo trafegam nos dois sentidos com baixa latência |
| Edição colaborativa | WebSocket | Cada tecla de cada pessoa é uma mensagem do cliente para o servidor |
| Atualização a cada minuto, sem urgência | Polling | Custa pouco, não exige infraestrutura nova e tolera atraso |
Uma observação sobre o streaming de LLM. A aula de system design perguntas complexas mostra esse caso, e ele é um dos mais comuns para SSE hoje. A resposta do modelo chega como texto que o servidor vai empurrando conforme gera, e o cliente só consome. A aula sobre mecânica da API Claude explica o streaming do lado da API. O que muda aqui é o transporte, não o modelo.
11. erros comuns e depuração
O erro mais frequente é a falta de flush. O código escreve o evento, o teste local parece funcionar porque o buffer enche rápido, e em produção o cliente só vê eventos em lotes ou nenhum. Em Go, todo evento precisa de um Flush logo depois do Write, e o Flush precisa ser checado: se ele retorna erro, a conexão morreu e o handler deve sair.
O segundo erro é o Content-Type errado. Um application/json ou um text/plain numa rota de eventos faz o navegador falhar a conexão, sem reconectar, como a seção 5 mostrou. O sintoma é um error com readyState em 2 logo na abertura. Confira o cabeçalho antes de procurar qualquer outro problema.
O terceiro erro é o proxy com buffer. Ele aparece quando tudo funciona com o servidor direto e falha atrás de um Nginx, de um ALB com configuração estranha ou de um CDN. O teste que separa os casos é chamar o servidor pela porta interna e pela URL pública e comparar o tempo de chegada de cada evento.
O quarto erro é o CORS sem credenciais. Com withCredentials: true, o navegador exige Access-Control-Allow-Origin com a origem exata e Access-Control-Allow-Credentials: true. Um * ali é recusado, e o erro aparece no console do navegador, não no seu log do servidor. Na aba de rede, a requisição parece ter chegado e ter ficado pendente, o que confunde.
O quinto erro é esquecer o close(). Cada EventSource aberto e não fechado é uma conexão a mais contra o limite de seis, e um componente React que monta uma conexão sem cleanup vai acumulá-las a cada navegação. O hook da seção 4 existe por esse motivo.
O sexto erro é o loop de reconexão, que a seção 5 explicou: uma resposta 200 com content-type certo que termina logo depois de começar. Ela é tratada como queda normal, e o navegador tenta de novo a cada retry. O sinal no servidor é uma sequência de pedidos com a mesma origem e intervalo regular, sem erro registrado.
Para depurar, o curl -N -i é a ferramenta de primeira linha. O -N desliga o buffer do curl, e o -i mostra os cabeçalhos. Se os eventos aparecem no terminal assim que o servidor os escreve, o problema não está no servidor nem no protocolo, e a investigação deve ir para o cliente ou para o proxy. Se eles só aparecem no fim, o problema é o buffer de algum intermediário. No navegador, a aba Network do DevTools lista a requisição de SSE, e o Chrome mostra os eventos numa aba chamada EventStream. Não verifiquei essa aba nesta sessão, então confirme o nome na versão que você usa.
12. exercícios
- Rode o servidor desta aula com
-tick 1s -ping 2s -buffer 4, conecte comcurl -N -ie depois com-H 'Last-Event-ID: 2'. Antes de rodar, calcule quais IDs o replay deve devolver e por quê. Depois compare o resultado com a sua previsão e explique qualquer diferença. - Escreva uma rota
/eventoscom um handler que envia 3 eventos e depois encerra a resposta com status 200. UseEventSourceno navegador e registrereadyStatenos eventoserroreopen. Explique por que o navegador reconecta nessa situação, e o que muda se você trocar o status para 204. - Adicione um endpoint de ticket que aceite um POST autenticado e devolva um valor de uso único com validade de 30 segundos. Mude a rota de eventos para aceitar
?ticket=e recuse tickets já usados. Depois descreva em um parágrafo o que um atacante que copiasse a URL do navegador conseguiria fazer.
13. glossário
- SSE: Server-Sent Events, o mecanismo em que o servidor envia uma sequência de eventos numa única resposta HTTP que não termina.
- EventSource: a API do navegador que abre e lê um stream de SSE.
- text/event-stream: o tipo MIME que a resposta de SSE precisa declarar.
- Evento: um bloco de campos terminado por linha em branco, que o navegador transforma num MessageEvent.
- Last-Event-ID: cabeçalho enviado pelo navegador na reconexão, com o último
idrecebido. - Heartbeat: comentário que o servidor manda para manter a conexão com movimento, sem gerar evento.
- Flush: envio imediato do que está no buffer do
ResponseWriterpara a rede. - Buffer circular: estrutura de tamanho fixo que sobrescreve os itens mais antigos, usada aqui para o replay.
- Hub: goroutine que é dona dos clientes e do buffer, e distribui os eventos para cada cliente.
- Fan-out: distribuição de um evento para várias instâncias do servidor por um broker, como o Redis em modo pub/sub.
- Upgrade: pedido HTTP que muda o protocolo da conexão, como o início de um WebSocket.
- Multiplexação: transporte de vários streams numa mesma conexão, como no HTTP/2.
Fontes
Consultadas em 2026-10-27:
- Anthropic, Streaming messages: stream true, tipos de evento, payload de content_block_delta, ping e erros dentro do stream. Consultada em 2026-10-27.
- WHATWG HTML Living Standard, seção Server-sent events: formato do stream, tratamento de status e de Content-Type, reconexão, Last-Event-ID e retry.
- MDN, Using server-sent events: sintaxe do stream e o limite de seis conexões por navegador e domínio em HTTP/1.1.
- MDN, EventSource: construtor, withCredentials, url, readyState e eventos.
- Documentação do pacote net/http: ResponseController, Flusher, Server.Shutdown e Server.WriteTimeout. Conferida também com
go docna toolchain Go 1.27.2 instalada. - Nginx, ngx_http_proxy_module: proxy_buffering, X-Accel-Buffering e proxy_read_timeout.
- AWS, Application Load Balancers: idle timeout padrão de 60 segundos.
- AWS, CloudFront, origin settings: response timeout, padrão de 30 segundos, e response completion timeout.
Saídas de execução capturadas em 2026-10-27: a conversa HTTP com curl -N -i, o replay com Last-Event-ID, o teste do cliente EventSource em Node 24.21.0, o teste do hook React com StrictMode e os testes do Go 1.27.2 com -race.
Não verificado: os casos de progresso, notificações, log de build e pedido são ilustrativos, com formatos de evento definidos para o exemplo, e não descrevem a implementação de produtos reais; o comportamento específico do CloudFront com streams de SSE, além dos timeouts; a configuração de compressão de cada proxy; as garantias de entrega do Redis pub/sub, descritas aqui pelo modelo geral; a documentação de @microsoft/fetch-event-source; a regra de CORS com credenciais, descrita a partir do conhecimento geral e não de uma página consultada; a aba EventStream do DevTools; o comportamento do EventSource nos navegadores reais, porque os testes de cliente rodaram na implementação experimental do Node; o cenário de StrictMode com a contagem de chamadas a close(), que não reexecutei nesta revisão; e o consumo de memória e de descritores por conexão, que não medi.
Relacionados
- Servidor HTTP Nativo Moderno em Go (1.22+)
- Middlewares em Go
- Context em Go
- Channels em Go
- Graceful Shutdown e Signal Handling
- Cliente HTTP profissional
- Amazon CloudFront
- System design sênior: 12 perguntas complexas com respostas
- Mecânica da API Claude
Anexo: código completo
O servidor de exemplo cabe num arquivo main.go e os testes num hub_test.go, ambos no módulo aulasse com go 1.22. Para rodar, use go run . -tick 2s -ping 15s e teste com curl -N -i http://localhost:8081/eventos.
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
)
// Evento é o que o servidor guarda e envia. O ID é atribuído pelo hub.
type Evento struct {
ID int64
Tipo string
Dados string
}
// buffer guarda os eventos mais recentes num anel: quando enche, o mais antigo é sobrescrito.
type buffer struct {
itens []Evento
prox int
cheio bool
}
func (b *buffer) adiciona(e Evento) {
b.itens[b.prox] = e
b.prox = (b.prox + 1) % len(b.itens)
if b.prox == 0 {
b.cheio = true
}
}
// depois devolve, em ordem de chegada, os eventos guardados com ID maior que o informado.
func (b *buffer) depois(id int64) []Evento {
var ordem []Evento
if b.cheio {
ordem = append(ordem, b.itens[b.prox:]...)
}
ordem = append(ordem, b.itens[:b.prox]...)
var saida []Evento
for _, e := range ordem {
if e.ID > id {
saida = append(saida, e)
}
}
return saida
}
type inscricao struct {
ultimoID int64
replayar bool
resposta chan resultado
}
type resultado struct {
replay []Evento
ch chan Evento
}
// Hub é o único dono de clientes e do buffer: só a goroutine de Run toca nesses dados,
// então não há mutex. Quem quer falar com ele manda mensagem por canal.
type Hub struct {
inscrever chan inscricao
desinscrever chan chan Evento
publicar chan Evento
feito chan struct{}
ping time.Duration
buf buffer
clientes map[chan Evento]struct{}
ultimoID int64
}
func novoHub(tamanhoBuffer int, ping time.Duration) *Hub {
return &Hub{
inscrever: make(chan inscricao),
desinscrever: make(chan chan Evento),
publicar: make(chan Evento),
feito: make(chan struct{}),
ping: ping,
buf: buffer{itens: make([]Evento, tamanhoBuffer)},
clientes: make(map[chan Evento]struct{}),
}
}
// Run atende os quatro tipos de pedido. Um cliente lento (canal cheio) é desconectado:
// ele reconecta com Last-Event-ID e recebe do buffer o que perdeu.
func (h *Hub) Run(ctx context.Context) {
defer close(h.feito)
for {
select {
case <-ctx.Done():
for ch := range h.clientes {
close(ch)
}
return
case in := <-h.inscrever:
ch := make(chan Evento, 8)
h.clientes[ch] = struct{}{}
var replay []Evento
if in.replayar {
replay = h.buf.depois(in.ultimoID)
}
in.resposta <- resultado{replay: replay, ch: ch}
case ch := <-h.desinscrever:
if _, ok := h.clientes[ch]; ok {
delete(h.clientes, ch)
close(ch)
}
case e := <-h.publicar:
h.ultimoID++
e.ID = h.ultimoID
h.buf.adiciona(e)
for ch := range h.clientes {
select {
case ch <- e:
default:
delete(h.clientes, ch)
close(ch)
}
}
}
}
}
// Publicar envia um evento ao hub. Se o hub já parou, desiste em vez de travar.
func (h *Hub) Publicar(tipo, dados string) {
select {
case h.publicar <- Evento{Tipo: tipo, Dados: dados}:
case <-h.feito:
}
}
// escreve formata um evento no formato do protocolo: campos id, event e data
// (uma linha por dado) e uma linha em branco para terminar o evento.
func escreve(w io.Writer, e Evento) error {
var b strings.Builder
fmt.Fprintf(&b, "id: %d\n", e.ID)
fmt.Fprintf(&b, "event: %s\n", e.Tipo)
for _, linha := range strings.Split(e.Dados, "\n") {
fmt.Fprintf(&b, "data: %s\n", linha)
}
b.WriteString("\n")
_, err := io.WriteString(w, b.String())
return err
}
func (h *Hub) eventos(w http.ResponseWriter, r *http.Request) {
valor := r.Header.Get("Last-Event-ID")
var ultimo int64
if n, err := strconv.ParseInt(valor, 10, 64); err == nil {
ultimo = n
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
if _, err := io.WriteString(w, "retry: 3000\n\n"); err != nil {
return
}
if err := rc.Flush(); err != nil {
return
}
resp := make(chan resultado, 1)
select {
case h.inscrever <- inscricao{ultimoID: ultimo, replayar: valor != "", resposta: resp}:
case <-h.feito:
return
case <-r.Context().Done():
return
}
res := <-resp
defer func() {
select {
case h.desinscrever <- res.ch:
case <-h.feito:
}
}()
for _, e := range res.replay {
if err := escreve(w, e); err != nil {
return
}
}
if err := rc.Flush(); err != nil {
return
}
ping := time.NewTicker(h.ping)
defer ping.Stop()
for {
select {
case <-r.Context().Done():
return
case <-ping.C:
if _, err := io.WriteString(w, ": ping\n\n"); err != nil {
return
}
if err := rc.Flush(); err != nil {
return
}
case e, aberto := <-res.ch:
if !aberto {
return
}
if err := escreve(w, e); err != nil {
return
}
if err := rc.Flush(); err != nil {
return
}
}
}
}
func (h *Hub) publicarHTTP(w http.ResponseWriter, r *http.Request) {
corpo, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4096))
if err != nil {
http.Error(w, "corpo ilegível ou grande demais", http.StatusBadRequest)
return
}
h.Publicar("mensagem", strings.TrimRight(string(corpo), "\n"))
w.WriteHeader(http.StatusAccepted)
}
func geraTicks(ctx context.Context, h *Hub, intervalo time.Duration) {
t := time.NewTicker(intervalo)
defer t.Stop()
n := 0
for {
select {
case <-ctx.Done():
return
case <-t.C:
n++
h.Publicar("tick", fmt.Sprintf(`{"n":%d}`, n))
}
}
}
func main() {
endereco := flag.String("addr", ":8081", "endereço em que o servidor escuta")
intervalo := flag.Duration("tick", 2*time.Second, "intervalo entre os eventos tick")
ping := flag.Duration("ping", 15*time.Second, "intervalo do heartbeat (comentário)")
tamanho := flag.Int("buffer", 16, "quantos eventos o buffer guarda para o Last-Event-ID")
flag.Parse()
ctx, parar := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer parar()
hub := novoHub(*tamanho, *ping)
go hub.Run(ctx)
go geraTicks(ctx, hub, *intervalo)
mux := http.NewServeMux()
mux.HandleFunc("GET /eventos", hub.eventos)
mux.HandleFunc("POST /publicar", hub.publicarHTTP)
srv := &http.Server{Addr: *endereco, Handler: mux}
go func() {
<-ctx.Done()
encerra, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelar()
if err := srv.Shutdown(encerra); err != nil {
log.Printf("shutdown: %v", err)
}
}()
log.Printf("ouvindo em %s", *endereco)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}
package main
import (
"context"
"testing"
"time"
)
func TestBufferCircular(t *testing.T) {
b := buffer{itens: make([]Evento, 3)}
for i := int64(1); i <= 5; i++ {
b.adiciona(Evento{ID: i})
}
if got := b.depois(0); len(got) != 3 || got[0].ID != 3 || got[2].ID != 5 {
t.Fatalf("depois(0) = %v, esperava ids 3,4,5", got)
}
if got := b.depois(4); len(got) != 1 || got[0].ID != 5 {
t.Fatalf("depois(4) = %v, esperava id 5", got)
}
}
func TestReplayPeloLastEventID(t *testing.T) {
ctx, cancelar := context.WithCancel(context.Background())
defer cancelar()
h := novoHub(16, time.Second)
go h.Run(ctx)
for i := 0; i < 5; i++ {
h.Publicar("tick", "x")
}
resp := make(chan resultado, 1)
h.inscrever <- inscricao{ultimoID: 3, replayar: true, resposta: resp}
res := <-resp
if len(res.replay) != 2 || res.replay[0].ID != 4 || res.replay[1].ID != 5 {
t.Fatalf("replay = %v, esperava ids 4 e 5", res.replay)
}
}
func TestSemLastEventIDNaoReplaya(t *testing.T) {
ctx, cancelar := context.WithCancel(context.Background())
defer cancelar()
h := novoHub(16, time.Second)
go h.Run(ctx)
h.Publicar("tick", "x")
resp := make(chan resultado, 1)
h.inscrever <- inscricao{replayar: false, resposta: resp}
if res := <-resp; len(res.replay) != 0 {
t.Fatalf("cliente novo recebeu replay: %v", res.replay)
}
}
func TestClienteLentoEDesconectado(t *testing.T) {
ctx, cancelar := context.WithCancel(context.Background())
defer cancelar()
h := novoHub(16, time.Second)
go h.Run(ctx)
resp := make(chan resultado, 1)
h.inscrever <- inscricao{resposta: resp}
res := <-resp
// O canal do cliente tem capacidade 8 e ninguém lê: o 9º publish o encontra cheio.
for i := 0; i < 9; i++ {
h.Publicar("tick", "x")
}
// Publicar só entrega o evento ao hub; a distribuição acontece depois. O hub atende um
// pedido por vez, então a resposta de uma inscrição extra prova que os 9 já foram distribuídos.
marca := make(chan resultado, 1)
h.inscrever <- inscricao{resposta: marca}
<-marca
n := 0
for {
select {
case _, aberto := <-res.ch:
if !aberto {
if n != 8 {
t.Fatalf("recebeu %d eventos antes de fechar, esperava 8", n)
}
return
}
n++
case <-time.After(2 * time.Second):
t.Fatal("o canal do cliente lento não foi fechado")
}
}
}