Destino Dev

Blog

Minimal APIs + SSE: enviar progresso de jobs para clientes Blazor passo a passo

Por John Pires · 23 de março de 2026

Minimal APIs + SSE: enviar progresso de jobs para clientes Blazor passo a passo

Introducao ao problema

O que queremos resolver

Você tem um servidor ASP.NET Core que executa jobs demorados (importações, processamento de imagens, geração de relatórios). Precisamos informar o cliente em tempo real sobre o progresso dessas tarefas, sem forçar refreshs ou polling agressivo. Blazor (Server ou WebAssembly) deve receber updates do servidor assim que eles acontecem.

Porque usar Minimal APIs e SSE

Minimal APIs simplificam a criação de endpoints em ASP.NET Core com pouco boilerplate, ideal para endpoints pequenos como um stream de eventos. Server-Sent Events (SSE) é um protocolo leve baseado em HTTP para enviar mensagens unidirecionais do servidor para o cliente. É mais simples que WebSockets para cenários de push simples (progresso, logs, notificações).

Esta combinação permite criar um endpoint /jobs/{id}/events que mantém uma conexão aberta e envia linhas no formato SSE: cada evento é prefixado por data: e termina em dupla quebra de linha. No cliente Blazor, podemos usar EventSource (via JS) para consumir esses eventos com muito pouco código.

Tip: SSE funciona bem em HTTP/1.1 e é fácil de depurar (você vê o fluxo raw no DevTools). Para comunicação bidirecional ou alta taxa, considere WebSockets.

Pre-requisitos e setup

Ferramentas e versões

Para seguir este guia você precisa de:

  • SDK .NET 7 ou superior

  • Visual Studio 2022 / VS Code

  • Conhecimentos básicos de C#, HTTP e Blazor

  • Navegador moderno com suporte a EventSource (Chrome, Edge, Firefox)

Projeto inicial

Crie uma solução com dois projetos: um ASP.NET Core Minimal API (server) e um Blazor WebAssembly (client). No servidor você vai expor endpoints para disparar jobs e para consumir o SSE. No cliente Blazor, usando EventSource via JS ou JS interop, vamos receber os eventos.

Comandos para criar a estrutura (terminal):

dotnet new web -o JobServer
cd JobServer
dotnet new sln -n JobSolution
dotnet sln add JobServer/JobServer.csproj

dotnet new blazorwasm -o JobClient --hosted false
dotnet sln add JobClient/JobClient.csproj

Esses comandos iniciam o servidor e o cliente. Vamos focar no servidor e no componente Blazor que consome o SSE. O cliente pode ser servido por um servidor estático ou pelo próprio SDK para desenvolvimento.

Tutorial guiado passo a passo

1) Implementando um job runner simples

Vamos criar um serviço que simula jobs longos e que reporta progresso via um mecanismo de publicação/assinatura. Usaremos channels ou IObservable, mas aqui mostrarei uma implementação com um registry simples e callbacks.

O código abaixo implementa um JobService com registro de listeners e execução simulada de job. Cada passo notifica o progresso.

using System.Collections.Concurrent;
using System.Text.Json;

public class JobProgress
{
    public string JobId { get; set; }
    public int Percent { get; set; }
    public string Message { get; set; }
}

public class JobService
{
    private readonly ConcurrentDictionary>> _listeners = new();

    public string StartJob()
    {
        var id = Guid.NewGuid().ToString();
        _ = RunJobAsync(id); // fire and forget (para demo)
        return id;
    }

    public void Subscribe(string jobId, Func handler)
    {
        var list = _listeners.GetOrAdd(jobId, _ => new List>());
        list.Add(handler);
    }

    public void Unsubscribe(string jobId, Func handler)
    {
        if (_listeners.TryGetValue(jobId, out var list))
            list.Remove(handler);
    }

    private async Task RunJobAsync(string jobId)
    {
        for (int i = 0; i <= 100; i += 10)
        {
            var progress = new JobProgress { JobId = jobId, Percent = i, Message = $"Step {i / 10}" };

            if (_listeners.TryGetValue(jobId, out var list))
            {
                foreach (var handler in list.ToArray())
                {
                    try { await handler(progress); } catch { /* ignore listener errors */ }
                }
            }

            await Task.Delay(500); // simula work
        }
    }
}

Contexto: esse serviço simula um processo que envia atualizações a cada 500ms. O registro de Func<JobProgress, Task> permite que múltiplos clientes ou endpoints escutem o progresso.

O que acontece depois: qualquer listener registrado para o jobId receberá callbacks com objetos JobProgress. Em produção, substitua a simulação por execução real.

2) Endpoint SSE em Minimal API

Agora criamos o endpoint SSE que mantém a conexão e envia eventos no formato correto. O endpoint vai subscrever o JobService e escrever mensagens no Response Body.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System.Text;

var builder = WebApplication.CreateBuilder();
builder.Services.AddSingleton();
var app = builder.Build();

app.MapPost("/jobs/start", (JobService svc) => Results.Ok(new { id = svc.StartJob() }));

app.MapGet("/jobs/{id}/events", async (string id, HttpResponse response, JobService svc, CancellationToken ct) =>
{
    response.Headers.Add("Cache-Control", "no-cache");
    response.Headers.Add("Content-Type", "text/event-stream");

    // Listener que escreve no response
    async Task Handler(JobProgress p)
    {
        if (ct.IsCancellationRequested) return;

        var json = System.Text.Json.JsonSerializer.Serialize(p);
        var payload = $"data: {json}\n\n"; // formato SSE
        var bytes = Encoding.UTF8.GetBytes(payload);
        await response.Body.WriteAsync(bytes.AsMemory(0, bytes.Length), ct);
        await response.Body.FlushAsync(ct);
    }

    // Registra e aguarda cancelamento
    svc.Subscribe(id, Handler);

    try
    {
        // Mantém a conexão viva até o cliente desconectar
        await Task.Delay(Timeout.Infinite, ct);
    }
    catch (OperationCanceledException) { }
    finally
    {
        svc.Unsubscribe(id, Handler);
    }
});

app.Run();

Contexto: esse endpoint escreve JSON serializado prefixado por data: e termina com uma linha em branco — requisito do SSE. Usamos cancellation token para detectar quando o cliente fecha a conexão.

O que acontece depois: quando o JobService dispara um progresso, o Handler serializa e escreve no corpo da resposta; o cliente receberá em tempo real cada evento.

3) Cliente Blazor consumindo SSE

No Blazor WebAssembly, a forma simples é usar JavaScript EventSource e chamar funções JS via JS interop. Você também pode usar interop direto dentro do .razor com código JS embutido. Abaixo há um exemplo de componente Blazor que conecta ao SSE e exibe o progresso.

@page "/job-progress"
@inject IJSRuntime JS

Job Progress Start Job
        @foreach(var p in events)
        {
           @(p.Percent)% - @p.Message
        }
    
@code {
    private List events = new();
    private DotNetObjectReference<JobProgressHandler> objRef;

    private async Task StartJob()
    {
        var client = new HttpClient { BaseAddress = new Uri(NavigationManager.BaseUri) };
        var resp = await client.PostAsync("/jobs/start", null);
        var json = await resp.Content.ReadFromJsonAsync<Dictionary<string,string>>();
        var id = json!['id'];

        objRef = DotNetObjectReference.Create(new JobProgressHandler(UpdateFromJs));
        await JS.InvokeVoidAsync("startSse", $"/jobs/{id}/events", objRef);
    }

    private void UpdateFromJs(JobProgress p)
    {
        events.Add(p);
        StateHasChanged();
    }

    public class JobProgressHandler
    {
        private readonly Action<JobProgress> _callback;
        public JobProgressHandler(Action<JobProgress> callback) => _callback = callback;
        [JSInvokable]
        public void Receive(string json)
        {
            var p = System.Text.Json.JsonSerializer.Deserialize<JobProgress>(json)!;
            _callback(p);
        }
    }

    public class JobProgress { public string JobId { get; set; } public int Percent { get; set; } public string Message { get; set; } }
}


Contexto: o componente dispara um POST para /jobs/start, recebe o id e chama uma função JS chamada startSse passando um DotNetObjectReference. Essa função cria um EventSource e, para cada evento recebido, invoca o método .NET via interop.

O que acontece depois: a UI do Blazor é atualizada por cada evento recebido. Essa abordagem mantém a parte de streaming no JS (EventSource) e a lógica de UI no Blazor.

Tip: Em Blazor Server você pode usar EventSource também no cliente, mas considere as diferenças de lifecycles; prefira JS interop com limpeza adequada.

Resultado final funcionando

Fluxo completo

Fluxo esperado quando tudo está em execução:

  1. Cliente Blazor envia POST /jobs/start

  2. Servidor cria job e retorna um id

  3. Cliente abre /jobs/{id}/events via EventSource

  4. Servidor executa job, publica progresso para listeners

  5. Endpoint SSE escreve cada progresso no formato data: {...}\n\n

  6. EventSource no cliente dispara callback e Blazor atualiza a UI

Verificações práticas

Para garantir que tudo está certo:

  • Use DevTools -> Network para ver a conexão /jobs/{id}/events aberta e eventos chegando.

  • Confirme que os headers incluem Content-Type: text/event-stream.

  • Em caso de proxies (NGINX), habilite suporte a streaming (disable buffering).

Se você rodar localmente, abra duas abas: uma para iniciar o job e outra para inspecionar o stream. Você verá linhas semelhantes a data: {"JobId":"...","Percent":10,"Message":"Step 1"}.

Erros comuns, armadilhas e dicas práticas

1) Headers e buffering

Um erro comum é esquecer de configurar o header Content-Type para text/event-stream ou ter um proxy que faz buffer e só entrega tudo no final. Isso quebra a percepção de tempo real.

Se estiver usando NGINX, habilite proxy_buffering off; para o endpoint SSE. Em servidores PaaS (Azure), verifique configurações de timeout para conexões longas.

2) Formatação SSE incorreta

SSE exige que cada evento termine com dupla quebra de linha. Se você enviar apenas uma, alguns navegadores aguardam por mais dados e não disparam o evento. Sempre envie data: ...\n\n.

3) Cancelamento e limpeza

Importante: remova listeners quando o cliente desconectar. No exemplo usamos CancellationToken para detectar desconexão e chamamos Unsubscribe no finally. Caso contrário, você acumula listeners e vaza memória.

Dica: durante desenvolvimento, logue quando listeners são adicionados/removidos para detectar vazamentos.

Desafio para o leitor praticar

Desafio 1: Persistir status no banco

Adapte o JobService para salvar o progresso no banco usando EF Core. Crie uma entidade JobStatus contendo JobId, Percent, LastUpdate e Message. Salve cada atualização e permita recuperar o estado via GET /jobs/{id}/status.

Desafio 2: Reconnect e recuperação

Implemente reconexão no cliente: quando o EventSource cair, tente reconectar automaticamente e envie o último percentual conhecido para o servidor para recuperar apenas eventos faltantes. Você pode adicionar um parâmetro ?fromPercent=XX ao URL do SSE para pedir updates a partir daquele ponto.

  1. No servidor, ao iniciar a conexão SSE, consulte o último status no banco e reenvie o último estado imediatamente.

  2. No cliente, armazene o último Percent recebido e, ao reconectar, inclua ?fromPercent=.

Esses exercícios consolidam conceitos de persistência e resiliência, frequentemente necessários em produção.

Conclusao pratica

Resumo dos pontos principais

Usar Minimal APIs + SSE é uma solução direta para enviar updates unidirecionais de servidor para cliente. A arquitetura proposta separa responsabilidades: JobService executa e notifica; endpoint SSE escreve os eventos; Blazor consome via EventSource e atualiza a UI. Essa abordagem reduz complexidade versus WebSockets para casos simples.

Próximos passos

Implemente os desafios, adicione testes e trate cenários de produção: buffering de proxies, escalabilidade (como compartilhar eventos entre instâncias usando Redis Pub/Sub), autenticação do stream (tokens) e limites de reconexão. Com esses cuidados, seu sistema ganha comunicação em tempo real com código simples e robusto.

Finalizando: a combinação é prática, de fácil entendimento e ideal para iniciantes entenderem push serverside. Aplique o padrão em tarefas de background e faça pequenas iterações para tornar a solução pronta para produção.

Assuntos: C#, aspnet-core, blazor, sse, minimal-apis

Leia também