Desafios dos Server-Sent Events em produção

Colocar Server-Sent Events em produção exige muito mais do que um endpoint que devolve um fluxo de texto. Nos últimos anos, vi o SSE crescer como alternativa leve para comunicação unidirecional em tempo real, mas também vi muitas implementações falharem ao sair da demonstração.

Por que Server-Sent Events em produção exigem cuidado

No ecossistema .NET, o suporte moderno a SSE, principalmente com Minimal APIs, tornou essa abordagem bastante atrativa. Em muitos cenários, ela compete diretamente com soluções mais completas, como o SignalR.

No entanto, a simplicidade conceitual esconde armadilhas. Autenticação, segmentação de usuários e execução por trás de um reverse proxy costumam ser ignoradas ou tratadas de forma superficial.

Por isso, este artigo apresenta uma abordagem prática para usar Server-Sent Events em produção no .NET, cobrindo três aspectos fundamentais:

  • Autenticação segura em conexões SSE
  • Entrega de eventos direcionados por usuário
  • Execução correta por trás de um reverse proxy

Se você ainda não conhece o recurso nativo do .NET, vale começar pelo artigo sobre Server-Sent Events no .NET 10. Aqui, o foco é o que acontece depois da prova de conceito.

Como os Server-Sent Events funcionam no .NET

Server-Sent Events usam conexões HTTP de longa duração, em que o servidor envia dados continuamente ao cliente como um fluxo de texto. No navegador, a API EventSource oferece uma forma simples de consumir esses eventos.

A principal vantagem está na simplicidade. Não há negociação de protocolo, múltiplos transportes nem estado complexo de conexão.

Em contrapartida, essa simplicidade impõe limitações importantes. Ao levar Server-Sent Events em produção, elas precisam de tratamento consciente, porque o protocolo não resolve nenhuma delas por você.

No ASP.NET Core do .NET 10, o método TypedResults.ServerSentEvents recebe um IAsyncEnumerable<SseItem<T>> e escreve cada item no formato correto. Segundo a documentação oficial de respostas em Minimal APIs, o recurso funciona tanto em Minimal APIs quanto em controllers.

O formato do fluxo de eventos

Cada evento é um bloco de linhas no formato campo: valor, terminado por uma linha em branco. Os campos mais relevantes são event, data, id e retry.

event: notification
id: 0195f3a1-7c2e-7b10-9a4e-3f1c2d8e9b01
retry: 5000
data: {"message":"New order received","createdAt":"2026-01-10T12:00:00Z"}

No .NET, o tipo SseItem<T> representa esse bloco. Além do payload em Data e do tipo em EventType, ele expõe as propriedades EventId e ReconnectionInterval.

Em Server-Sent Events em produção, o campo id merece atenção especial. Quando a conexão cai, o navegador reconecta sozinho e envia o último id recebido no header Last-Event-ID, o que abre espaço para o replay de mensagens.

Autenticação de Server-Sent Events em produção

O primeiro desafio relevante aparece na autenticação. Em APIs comuns, o cliente envia Authorization: Bearer <token> e o middleware de autenticação faz o resto.

Com SSE, essa receita não funciona no navegador. Por isso, a autenticação é o ponto que mais derruba Server-Sent Events em produção.

O problema dos headers no EventSource

A API EventSource do JavaScript não permite o envio de headers customizados. O construtor aceita apenas a URL e um objeto com a opção withCredentials.

Dessa forma, abordagens tradicionais baseadas em Bearer token ficam inviáveis para Server-Sent Events em produção. Diante disso, surgem duas alternativas práticas:

  1. Cookies de autenticação
  2. Parâmetros de query string

Se você precisa revisar como o token funciona, o artigo o que é JWT explica a estrutura e a validação em detalhes.

Cookies de autenticação

A primeira opção é, sem dúvida, a mais segura quando aplicável. O navegador envia cookies automaticamente, então a conexão SSE chega ao servidor já autenticada.

Na prática, cookies funcionam bem para Server-Sent Events em produção com autenticação baseada em sessão ou com JWT armazenado em cookie HttpOnly. Nesse caso, o endpoint SSE usa RequireAuthorization() como qualquer outra rota.

Contudo, cookies nem sempre estão disponíveis. Front-ends hospedados em outro domínio, aplicativos móveis e clientes que já guardam o token em memória costumam cair na segunda opção.

O risco da query string

Enviar o token de acesso diretamente pela query string é problemático. Esses valores aparecem em logs de servidor, caches intermediários, histórico do navegador e ferramentas de observabilidade.

Com Server-Sent Events em produção, isso representa um risco de segurança significativo. Um token de acesso com validade de uma hora, gravado em log, vira uma credencial exposta durante uma hora inteira.

Para mitigar esse problema, uma alternativa eficaz é usar tokens temporários de curta duração. Chamamos esses tokens de Short-Lived Tokens, ou SLT.

Tokens temporários para Server-Sent Events em produção

A estratégia introduz um endpoint intermediário de autenticação. O fluxo segue três passos:

  1. O cliente se autentica normalmente, com o Bearer token de sempre
  2. Em seguida, solicita um token temporário ao backend
  3. Por fim, usa esse token apenas para iniciar a conexão SSE

Esse token é aleatório e tem validade extremamente curta, como 30 segundos. Além disso, fica armazenado temporariamente em memória ou em um cache distribuído como o Redis.

No backend, mantemos um mapeamento entre o token gerado e o identificador do usuário. Se o cliente não usar o token dentro do prazo, o cache o descarta automaticamente.

using System.Security.Cryptography;
using Microsoft.Extensions.Caching.Memory;

public sealed class SseTokenService(IMemoryCache cache)
{
    private static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(30);

    public string Issue(string userId)
    {
        // 32 bytes aleatórios, gerados por um RNG criptográfico
        var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
        cache.Set($"sse-token:{token}", userId, Lifetime);
        return token;
    }

    public string? Redeem(string token)
    {
        var key = $"sse-token:{token}";
        if (!cache.TryGetValue(key, out string? userId))
            return null;

        // uso único: o token não vale para uma segunda conexão
        cache.Remove(key);
        return userId;
    }
}

O endpoint que emite o token fica protegido pela autenticação normal da API. Assim, só um usuário autenticado consegue obter um SLT.

app.MapPost("/sse/token", (ClaimsPrincipal user, SseTokenService tokens) =>
{
    var userId = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
    return TypedResults.Ok(new { token = tokens.Issue(userId) });
}).RequireAuthorization();

Com isso, a superfície de ataque dos Server-Sent Events em produção cai drasticamente, mesmo usando query string. Um token que aparece em log já expirou ou já foi consumido.

Direcionamento de Server-Sent Events em produção por usuário

Autenticar o usuário é apenas o primeiro passo. Em aplicações reais, raramente queremos transmitir eventos globais.

Na maioria dos cenários de Server-Sent Events em produção, os eventos precisam chegar a usuários específicos. Uma notificação de pedido aprovado, por exemplo, interessa apenas ao dono do pedido.

Escopando conexões com Channels

Para resolver esse problema, usamos os Channels do .NET como mecanismo de entrega. Cada usuário possui seu próprio canal, que transmite apenas os eventos relevantes à sua identidade.

A arquitetura funciona assim:

  • Um Connection Manager mantém um dicionário em memória
  • A chave é o identificador do usuário
  • O valor é o canal associado a esse usuário

Esse gerenciador fica registrado como singleton e atua como ponto central de roteamento. Se Channels são novidade para você, o artigo sobre Channels em C# mostra o modelo produtor e consumidor em detalhes.

using System.Collections.Concurrent;
using System.Net.ServerSentEvents;
using System.Threading.Channels;

public record Notification(string Message, DateTimeOffset CreatedAt);

public sealed class ConnectionManager
{
    private readonly ConcurrentDictionary<string, Channel<SseItem<Notification>>> _channels = new();

    public Channel<SseItem<Notification>> GetOrCreate(string userId) =>
        _channels.GetOrAdd(userId, _ => Channel.CreateBounded<SseItem<Notification>>(
            new BoundedChannelOptions(100)
            {
                // descarta o evento mais antigo quando o cliente não acompanha
                FullMode = BoundedChannelFullMode.DropOldest
            }));

    public bool TryPublish(string userId, SseItem<Notification> item) =>
        _channels.TryGetValue(userId, out var channel) && channel.Writer.TryWrite(item);

    public void Remove(string userId) => _channels.TryRemove(userId, out _);
}

O canal limitado evita que um cliente lento consuma memória sem limite. Essa decisão importa muito para Server-Sent Events em produção, porque conexões presas são comuns em redes móveis.

Essa abordagem lembra o gerenciamento interno de conexões do SignalR. A diferença é que aqui tudo fica explícito e sob seu controle.

Existe, porém, um detalhe importante. Um canal entrega cada item a um único leitor, então duas abas do mesmo usuário competem pelos eventos; se isso for um requisito, crie um canal por conexão e agrupe os canais pelo identificador do usuário.

Consumindo eventos no endpoint SSE

No endpoint de Server-Sent Events, o fluxo segue quatro etapas:

  1. Primeiro, o endpoint valida o token temporário
  2. Depois, recupera o identificador do usuário
  3. Em seguida, obtém o canal correspondente
  4. Por fim, transmite os eventos ao cliente com ReadAllAsync
app.MapGet("/sse/notifications", Results<UnauthorizedHttpResult, ServerSentEventsResult<Notification>> (
    string token,
    SseTokenService tokens,
    ConnectionManager connections,
    CancellationToken cancellationToken) =>
{
    var userId = tokens.Redeem(token);
    if (userId is null)
        return TypedResults.Unauthorized();

    var channel = connections.GetOrCreate(userId);
    return TypedResults.ServerSentEvents(channel.Reader.ReadAllAsync(cancellationToken));
});

Esse fluxo garante que cada conexão de Server-Sent Events em produção receba exclusivamente os eventos do usuário autenticado. Quando o cliente fecha a aba, o CancellationToken da requisição encerra a leitura do canal.

Replay de eventos com Last-Event-ID

Além disso, a solução suporta replay de eventos com o header Last-Event-ID. Assim, o cliente recupera mensagens perdidas durante uma reconexão.

Para isso, o servidor de Server-Sent Events em produção precisa guardar um histórico curto por usuário. No exemplo abaixo, EventHistory representa esse armazenamento, que pode ser uma lista em memória ou um stream no Redis.

static async IAsyncEnumerable<SseItem<Notification>> StreamAsync(
    string userId,
    string? lastEventId,
    EventHistory history,
    ChannelReader<SseItem<Notification>> reader,
    [EnumeratorCancellation] CancellationToken cancellationToken)
{
    // reenvia primeiro o que o cliente perdeu
    foreach (var missed in history.GetAfter(userId, lastEventId))
        yield return missed;

    await foreach (var item in reader.ReadAllAsync(cancellationToken))
        yield return item;
}

Há uma interação sutil com o token de uso único. Como o navegador reconecta com a mesma URL, o token já consumido gera um 401 e o EventSource desiste da conexão.

Nesse caso, o cliente precisa pedir um novo token e abrir outro EventSource. Como uma nova instância não envia Last-Event-ID, vale aceitar o último id também como parâmetro de query.

Produzindo eventos para usuários específicos

Do lado do produtor, o fluxo de Server-Sent Events em produção é igualmente importante. Em sistemas reais, os eventos nascem de várias fontes:

  • Escritas em banco de dados
  • Processamento assíncrono
  • Mensageria, como RabbitMQ, Azure Service Bus ou Kafka

Para demonstrar, podemos usar um BackgroundService que gera eventos periodicamente e os publica nos canais dos usuários selecionados. O artigo sobre Background Services em C# e .NET aprofunda esse tipo de serviço.

public sealed class NotificationProducer(
    ConnectionManager connections,
    ILogger<NotificationProducer> logger) : BackgroundService
{
    private static readonly string[] UserIds = ["user-1", "user-2", "user-3"];

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            var userId = UserIds[Random.Shared.Next(UserIds.Length)];
            var notification = new Notification($"New event for {userId}", DateTimeOffset.UtcNow);

            var item = new SseItem<Notification>(notification, "notification")
            {
                EventId = Guid.CreateVersion7().ToString()
            };

            if (!connections.TryPublish(userId, item))
                logger.LogDebug("User {UserId} is not connected", userId);
        }
    }
}

O EventId baseado em GUID versão 7 é ordenável por tempo. Isso facilita bastante a busca dos eventos posteriores ao Last-Event-ID.

Em Server-Sent Events em produção, esse padrão desacopla completamente três peças: a origem do evento, o mecanismo de entrega e o consumidor final. Portanto, trocar o timer por uma fila real não altera o endpoint SSE.

Escalando Server-Sent Events em produção

O Connection Manager vive na memória de uma única instância. Quando a aplicação roda com várias réplicas, o produtor pode publicar em uma instância enquanto o usuário está conectado em outra.

Em ambientes distribuídos, estenda a abordagem com Redis ou outro mecanismo de pub/sub. Cada instância assina o tópico e entrega localmente apenas os eventos dos usuários conectados a ela.

Server-Sent Events em produção atrás de um reverse proxy

Outro ponto crítico, frequentemente negligenciado, é rodar SSE atrás de um reverse proxy. Como as conexões são de longa duração, o proxy precisa cumprir dois requisitos.

  • Não encerrar a conexão prematuramente
  • Suportar streaming contínuo da resposta, sem buffering

Quando qualquer um deles falha, o sintoma em Server-Sent Events em produção é confuso. Os eventos chegam em rajadas, chegam atrasados ou a conexão cai em intervalos regulares.

Usando YARP como reverse proxy

Para cenários como este, o YARP (Yet Another Reverse Proxy) é uma solução moderna e oficial da Microsoft. A configuração mínima envolve três passos:

  • Adicionar o pacote Yarp.ReverseProxy
  • Registrar o HttpForwarder
  • Mapear uma rota curinga que encaminha todas as requisições ao backend
using Yarp.ReverseProxy.Forwarder;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpForwarder();

var app = builder.Build();

var requestConfig = new ForwarderRequestConfig
{
    // o padrão é 100 segundos sem atividade
    ActivityTimeout = TimeSpan.FromMinutes(10)
};

app.MapForwarder("/{**catch-all}", "https://localhost:5001/", requestConfig);

app.Run();

Uma vez configurado, o proxy atua apenas como intermediário. Ele mantém a conexão aberta enquanto o backend continua emitindo eventos.

O resultado é exatamente o esperado: o cliente se conecta ao proxy e os eventos fluem como se ele estivesse ligado direto ao servidor de origem. A documentação de direct forwarding do YARP descreve as demais opções de MapForwarder.

Timeouts e buffering no proxy

O ActivityTimeout do YARP cancela requisições ociosas depois de 100 segundos por padrão. Cada evento escrito reinicia a contagem, mas um usuário sem notificações por dois minutos perde a conexão.

Por isso, em Server-Sent Events em produção, combine um timeout maior com eventos de heartbeat periódicos. Um evento leve a cada 15 ou 30 segundos mantém o proxy, o balanceador e o firewall convencidos de que a conexão está viva.

Além disso, a documentação do YARP alerta que a opção AllowResponseBuffering pode quebrar cenários de SSE. Deixe essa opção desligada nas rotas de streaming.

O mesmo cuidado vale para outros proxies. No Nginx, por exemplo, o header de resposta X-Accel-Buffering: no desliga o buffering para aquela requisição.

Expiração de tokens em Server-Sent Events em produção

Um detalhe merece atenção: a autenticação ocorre apenas no momento da criação da conexão SSE. Se o token de acesso do usuário expirar com a conexão aberta, não há revalidação automática.

Na prática, isso não é um comportamento anômalo. Uma requisição HTTP longa continua válida enquanto estiver em execução, assim como um download grande iniciado com um token válido.

Ainda assim, quando Server-Sent Events em produção exigem um controle mais rigoroso, o cliente pode agir de forma proativa:

  • Monitorar a expiração do token de acesso
  • Solicitar um novo token temporário
  • Reabrir a conexão SSE antes do vencimento

Outra opção fica do lado do servidor. O endpoint pode encerrar o stream quando a sessão expira, forçando o cliente a se autenticar de novo.

SignalR ou Server-Sent Events em produção

Tudo o que vimos até aqui foi construído à mão: tokens, roteamento por usuário, replay e escala horizontal. O SignalR entrega boa parte disso pronto, incluindo grupos, usuários e backplane com Redis.

Por outro lado, o SignalR exige uma biblioteca cliente e um protocolo próprio. Já o SSE roda sobre HTTP puro e funciona em qualquer navegador sem dependências.

Então, a escolha entre SignalR e Server-Sent Events em produção depende do cenário. Para notificações unidirecionais simples, SSE costuma bastar; para comunicação bidirecional ou regras complexas de grupos, o artigo sobre SignalR com Minimal APIs é um bom ponto de partida.

Conclusão

Server-Sent Events são uma ferramenta poderosa, simples e eficiente quando bem aplicada. No entanto, levar Server-Sent Events em produção exige cuidados que vão muito além de uma prova de conceito.

Ao tratar corretamente a autenticação, o direcionamento de eventos e a execução por trás de proxies, você constrói uma solução robusta, escalável e segura para aplicações modernas em .NET.

Em cenários mais complexos, frameworks como o SignalR continuam extremamente valiosos, porque abstraem grande parte dessa complexidade. Ainda assim, compreender os fundamentos de Server-Sent Events em produção permite tomar decisões mais conscientes e arquiteturalmente sólidas.

FAQ

Por que o EventSource não aceita o header Authorization?

A especificação do EventSource define apenas a URL e a opção withCredentials no construtor. Por isso, a autenticação precisa usar cookies ou parâmetros na URL.

Passar token na query string é seguro?

Passar o token de acesso principal não é seguro, porque ele acaba em logs e caches. Um token temporário, de uso único e com validade de poucos segundos reduz muito esse risco.

Como enviar eventos apenas para um usuário específico?

Mantenha um canal por usuário em um serviço singleton e publique cada evento no canal do destinatário. O endpoint SSE lê somente o canal do usuário autenticado.

Por que minha conexão SSE cai depois de alguns minutos?

Geralmente, um proxy ou balanceador encerra conexões ociosas. Aumente o timeout de atividade e envie eventos de heartbeat periódicos.

Server-Sent Events funcionam com várias instâncias da aplicação?

Funcionam, desde que você sincronize os eventos entre as instâncias. Um pub/sub como o Redis distribui cada evento para a instância em que o usuário está conectado.

Glossário

Termo Significado
SSE Server-Sent Events, padrão em que o servidor envia eventos ao cliente por uma conexão HTTP aberta
EventSource API do navegador que abre e consome um fluxo de Server-Sent Events
SseItem Tipo do .NET que representa um evento, com payload, tipo, id e intervalo de reconexão
Last-Event-ID Header enviado pelo navegador na reconexão com o id do último evento recebido
SLT Short-Lived Token, token temporário de validade curta usado só para abrir a conexão
Channel Estrutura do .NET para troca assíncrona de mensagens entre produtores e consumidores
Reverse proxy Servidor intermediário que recebe as requisições do cliente e as encaminha ao backend
YARP Yet Another Reverse Proxy, biblioteca da Microsoft para criar proxies reversos em .NET
ActivityTimeout Tempo máximo que o YARP permite uma requisição ficar ociosa antes de cancelá-la
Heartbeat Evento periódico enviado apenas para manter a conexão ativa

Fontes