Pular para o conteúdo

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

Tamanho da fonte:
Largura da coluna:

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.

Quatro formas de o servidor avisar o navegador
figura 1 · Quatro formas de o servidor avisar o navegador

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érioPollingLong pollingSSEWebSocket
Quem inicia cada mensagemClienteClienteServidor, depois de um GET inicialQualquer lado
DireçãoCliente pergunta, servidor respondeIgual ao polling, mas a resposta espera o eventoSó do servidor para o clienteNos dois sentidos
Requisições para N eventosN ou mais, a maioria vaziaUma por eventoUma para o stream inteiroUma conexão, depois do upgrade
ProtocoloHTTP comumHTTP comumHTTP com ws:// ou wss://, após o upgrade
ReconexãoSeu códigoSeu códigoO navegador, com Last-Event-IDSeu código
Formato do dadoO que a API devolverO que a API devolverTexto UTF-8Texto ou binário
Passa por proxies e CDNs comunsSimSimSim, com cuidados de buffer e timeoutDepende 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.

Uma resposta HTTP que nunca fecha
figura 2 · Uma resposta HTTP que nunca fecha

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.

Anatomia de um evento SSE
figura 3 · Anatomia de um evento SSE

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.

Os três estados do EventSource
figura 4 · Os três estados do EventSource

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.

Sequência de reconexão
figura 5 · Sequência de reconexão

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.

O hub em Go por dentro
figura 6 · O hub em Go por dentro

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.

HTTP/1.1 e HTTP/2 com SSE
figura 7 · HTTP/1.1 e HTTP/2 com SSE

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.

SSE em produção
figura 8 · SSE em produção

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.

CasoEscolhaPor quê
Notificações de eventos do sistemaSSEFluxo unidirecional, reconexão automática e pouco custo por usuário
Progresso de um job longoSSEO servidor empurra o percentual, e o cliente só observa
Feed de atualizaçõesSSENovidades chegam do servidor, e o cliente não precisa responder a cada item
Dashboard com métricasSSELeitura contínua, com reconexão e buffer para lacunas curtas
Streaming de tokens de um LLMSSEA resposta é uma sequência de texto do servidor, como em system design perguntas complexas
Chat multiusuárioWebSocketCada participante envia e recebe o tempo todo
Jogo multiplayerWebSocketEntrada do jogador e estado do mundo trafegam nos dois sentidos com baixa latência
Edição colaborativaWebSocketCada tecla de cada pessoa é uma mensagem do cliente para o servidor
Atualização a cada minuto, sem urgênciaPollingCusta 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

  1. Rode o servidor desta aula com -tick 1s -ping 2s -buffer 4, conecte com curl -N -i e 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.
  2. Escreva uma rota /eventos com um handler que envia 3 eventos e depois encerra a resposta com status 200. Use EventSource no navegador e registre readyState nos eventos error e open. Explique por que o navegador reconecta nessa situação, e o que muda se você trocar o status para 204.
  3. 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 id recebido.
  • 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 ResponseWriter para 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:

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

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")
		}
	}
}

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?