← Volver al blogServer-Sent Events con .NET 10 y React: progreso en tiempo real sin WebSockets
dotnetaspnet-corereactserver-sent-eventsreal-time

Server-Sent Events con .NET 10 y React: progreso en tiempo real sin WebSockets

Construimos una aplicación que genera reportes en segundo plano y transmite su progreso desde una Minimal API de .NET 10 hacia React mediante Server-Sent Events, con eventos tipados, reconexión automática y reanudación usando Last-Event-ID.

Construimos una aplicación que genera reportes en segundo plano y transmite su progreso desde una Minimal API de .NET 10 hacia React mediante Server-Sent Events, con eventos tipados, reconexión automática y reanudación usando Last-Event-ID.

Cuando el navegador solo necesita recibir actualizaciones del servidor, una conexión bidireccional puede ser más infraestructura de la necesaria. SSE mantiene una petición HTTP abierta y deja que el backend envíe eventos a medida que ocurren.


La frase “actualizaciones en tiempo real” suele llevarnos directamente a WebSockets o SignalR. Son buenas herramientas, pero no todos los problemas necesitan comunicación en ambas direcciones.

Un generador de reportes es un buen ejemplo:

  1. React envía un POST para iniciar el trabajo.
  2. La API responde inmediatamente con un identificador.
  3. El navegador abre un stream para ese trabajo.
  4. .NET envía el porcentaje y la etapa actual.
  5. React actualiza la interfaz hasta recibir el evento de finalización.

Los comandos continúan usando HTTP normal. El canal persistente se utiliza únicamente para el flujo servidor → cliente.

En .NET 10 este patrón resulta más directo porque ASP.NET Core incorpora TypedResults.ServerSentEvents. Ya no necesitamos escribir manualmente cada línea event:, data:, id: y retry: del protocolo.

Qué vamos a construir

La aplicación tendrá dos endpoints:

flowchart LR
    accTitle: Arquitectura de la aplicación SSE
    accDescr: React inicia un reporte, la API registra el trabajo y el endpoint SSE transmite el progreso.
    React["React · interfaz"] -->|"POST /api/reports"| Start["Minimal API · iniciar trabajo"]
    Start -->|"202 Accepted · id"| React
    Start --> Store["ReportJobStore · historial"]
    React -->|"GET /api/reports/{id}/events"| Stream["TypedResults.ServerSentEvents"]
    Store -->|"IAsyncEnumerable of SseItem"| Stream
    Stream -->|"progress · id · retry"| React

El flujo completo queda así:

sequenceDiagram
    accTitle: Flujo completo de un reporte con SSE
    accDescr: React inicia el reporte, abre EventSource y recibe eventos hasta completar el trabajo.
    participant R as React
    participant A as ASP.NET Core
    participant J as ReportJobStore
    R->>A: POST /api/reports
    A->>J: Start()
    J-->>A: reportId
    A-->>R: 202 Accepted { id }
    R->>A: GET /api/reports/{id}/events
    activate A
    loop mientras trabaja
      J-->>A: ReportProgress
      A-->>R: event progress · id n
    end
    A-->>R: event completed · 100%
    deactivate A
    R->>R: source.close()

Animación del flujo completo: creación del trabajo, apertura de EventSource, corte de red, reconexión con Last-Event-ID: 3 y replay exclusivo de los eventos faltantes.

No instalaremos una librería de tiempo real en React. EventSource forma parte de la plataforma web y los navegadores modernos implementan reconexión automática.

SSE, WebSockets o SignalR

La elección depende de la dirección y complejidad de la comunicación:

Necesidad Mejor punto de partida
Progreso, notificaciones, métricas, logs o streaming de texto desde el servidor SSE
Mensajes frecuentes en ambas direcciones o contenido binario WebSockets
Hubs, grupos, invocación cliente-servidor y una abstracción completa en .NET SignalR
Actualizaciones poco frecuentes donde algunos segundos de retraso son aceptables Polling

SSE tiene cuatro propiedades útiles para este caso:

  • Funciona sobre HTTP y usa el tipo text/event-stream.
  • El servidor puede asignar un nombre a cada evento.
  • El navegador intenta reconectarse si la conexión se corta.
  • El campo id permite continuar desde el último evento recibido.

También tiene límites claros: el canal es unidireccional, los mensajes son texto y la API nativa EventSource no permite agregar un header Authorization arbitrario.

Qué aporta .NET 10

ASP.NET Core 10 agregó tres overloads de TypedResults.ServerSentEvents:

  • Un stream de strings.
  • Un IAsyncEnumerable<T> con un tipo de evento común.
  • Un IAsyncEnumerable<SseItem<T>> para controlar el tipo, identificador y tiempo de reconexión de cada evento.

Usaremos la tercera opción:

new SseItem<ReportProgress>(progress, "progress")
{
    EventId = progress.Sequence.ToString(),
    ReconnectionInterval = TimeSpan.FromSeconds(2),
};

Los objetos se serializan como JSON con las opciones configuradas en ASP.NET Core. Para strings, el resultado escribe el contenido sin serialización adicional.

Crear la API

Partimos de una aplicación web mínima:

dotnet new web -n SseDemo.Api -f net10.0
cd SseDemo.Api

Los contratos contienen el identificador que devuelve el POST y el estado que viaja por SSE:

public sealed record ReportCreated(Guid Id);

public sealed record ReportProgress(
    long Sequence,
    int Percentage,
    string Stage,
    bool Completed);

Los endpoints en Program.cs

La API registra CORS para el servidor de desarrollo de React, inicia trabajos mediante POST y expone el stream mediante GET:

using System.Globalization;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddCors(options =>
{
    options.AddPolicy("react", policy =>
    {
        policy
            .WithOrigins("http://localhost:5173")
            .AllowAnyHeader()
            .WithMethods("GET", "POST");
    });
});
builder.Services.AddSingleton<ReportJobStore>();

WebApplication app = builder.Build();

app.UseCors("react");

app.MapPost("/api/reports", (ReportJobStore jobs) =>
{
    Guid reportId = jobs.Start();
    return TypedResults.Accepted(
        $"/api/reports/{reportId}/events",
        new ReportCreated(reportId));
});

app.MapGet(
    "/api/reports/{reportId:guid}/events",
    IResult (Guid reportId, HttpContext context, ReportJobStore jobs) =>
    {
        if (!jobs.Contains(reportId))
        {
            return TypedResults.NotFound();
        }

        context.Response.Headers.CacheControl = "no-cache";
        context.Response.Headers["X-Accel-Buffering"] = "no";

        string? lastEventId = context.Request.Headers["Last-Event-ID"].FirstOrDefault();
        long afterSequence = long.TryParse(
            lastEventId,
            CultureInfo.InvariantCulture,
            out long parsed)
            ? parsed
            : 0;

        return TypedResults.ServerSentEvents(
            jobs.Subscribe(
                reportId,
                afterSequence,
                context.RequestAborted));
    });

app.Run();

Hay varios detalles importantes:

  1. El endpoint SSE devuelve TypedResults.ServerSentEvents.
  2. RequestAborted cancela el enumerador cuando el navegador cierra la conexión.
  3. Cache-Control: no-cache evita que una capa intermedia trate el stream como una respuesta reutilizable.
  4. X-Accel-Buffering: no desactiva buffering cuando el proxy es Nginx.
  5. El servidor lee Last-Event-ID para saber desde qué secuencia debe reanudar.

El header X-Accel-Buffering es específico de Nginx. En otro proxy hay que comprobar su configuración equivalente; lo importante es que no acumule varios eventos antes de enviarlos.

Guardar progreso y permitir replay

El store de ejemplo conserva los eventos de cada trabajo durante diez minutos:

using System.Collections.Concurrent;
using System.Globalization;
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;

public sealed class ReportJobStore
{
    private readonly ConcurrentDictionary<Guid, ReportJob> _jobs = new();
    private readonly CancellationToken _applicationStopping;

    public ReportJobStore(IHostApplicationLifetime lifetime)
    {
        _applicationStopping = lifetime.ApplicationStopping;
    }

    public Guid Start()
    {
        Guid reportId = Guid.NewGuid();
        ReportJob job = new();
        job.Publish(new ReportProgress(1, 0, "Queued", false));

        if (!_jobs.TryAdd(reportId, job))
        {
            throw new InvalidOperationException("Could not register the report job.");
        }

        _ = RunAsync(reportId, job, _applicationStopping);
        return reportId;
    }

    public bool Contains(Guid reportId) => _jobs.ContainsKey(reportId);

    public async IAsyncEnumerable<SseItem<ReportProgress>> Subscribe(
        Guid reportId,
        long afterSequence,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        if (!_jobs.TryGetValue(reportId, out ReportJob? job))
        {
            yield break;
        }

        long cursor = afterSequence;

        while (!cancellationToken.IsCancellationRequested)
        {
            JobSnapshot snapshot = job.ReadAfter(cursor);

            foreach (ReportProgress progress in snapshot.Events)
            {
                cursor = progress.Sequence;

                yield return new SseItem<ReportProgress>(
                    progress,
                    progress.Completed ? "completed" : "progress")
                {
                    EventId = progress.Sequence.ToString(CultureInfo.InvariantCulture),
                    ReconnectionInterval = TimeSpan.FromSeconds(2),
                };
            }

            if (snapshot.Completed && cursor >= snapshot.LastSequence)
            {
                yield break;
            }

            await snapshot.Changed.WaitAsync(cancellationToken);
        }
    }

    private async Task RunAsync(
        Guid reportId,
        ReportJob job,
        CancellationToken cancellationToken)
    {
        (int Percentage, string Stage)[] steps =
        [
            (20, "Reading source data"),
            (45, "Calculating totals"),
            (70, "Rendering charts"),
            (90, "Writing the PDF"),
        ];

        long sequence = 2;

        try
        {
            foreach ((int percentage, string stage) in steps)
            {
                await Task.Delay(TimeSpan.FromMilliseconds(700), cancellationToken);
                job.Publish(
                    new ReportProgress(
                        sequence++,
                        percentage,
                        stage,
                        false));
            }

            await Task.Delay(TimeSpan.FromMilliseconds(700), cancellationToken);
            job.Publish(
                new ReportProgress(
                    sequence,
                    100,
                    "Report ready",
                    true));

            await Task.Delay(TimeSpan.FromMinutes(10), cancellationToken);
            _jobs.TryRemove(reportId, out ReportJob? _);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
        }
    }

    private sealed class ReportJob
    {
        private readonly object _gate = new();
        private readonly List<ReportProgress> _events = [];
        private TaskCompletionSource<bool> _changed = CreateSignal();
        private bool _completed;

        public void Publish(ReportProgress progress)
        {
            TaskCompletionSource<bool> changed;

            lock (_gate)
            {
                _events.Add(progress);
                _completed = progress.Completed;
                changed = _changed;
                _changed = CreateSignal();
            }

            changed.TrySetResult(true);
        }

        public JobSnapshot ReadAfter(long sequence)
        {
            lock (_gate)
            {
                ReportProgress[] events = _events
                    .Where(progress => progress.Sequence > sequence)
                    .ToArray();
                long lastSequence = _events.Count == 0
                    ? 0
                    : _events[^1].Sequence;

                return new JobSnapshot(
                    events,
                    _completed,
                    lastSequence,
                    _changed.Task);
            }
        }

        private static TaskCompletionSource<bool> CreateSignal() =>
            new(TaskCreationOptions.RunContinuationsAsynchronously);
    }

    private sealed record JobSnapshot(
        ReportProgress[] Events,
        bool Completed,
        long LastSequence,
        Task Changed);
}

La implementación usa dos ideas:

  • Una lista mantiene el historial necesario para reanudar desde un identificador.
  • Un TaskCompletionSource despierta a todos los suscriptores cuando aparece un evento.

Esto último es intencional. Un solo Channel<T> con varios readers distribuye elementos entre consumidores; no los transmite a todos. Si dos pestañas observaran el mismo reporte, podrían recibir eventos diferentes. Para convertir Channels en broadcast necesitaríamos un canal por suscriptor o una capa de pub/sub.

Cada SseItem<ReportProgress> incluye:

  • EventType: progress o completed.
  • EventId: la secuencia del evento.
  • ReconnectionInterval: dos segundos.
  • Data: el objeto que ASP.NET Core serializa a JSON.

El resultado real en la red se parece a esto:

event: progress
data: {"sequence":3,"percentage":45,"stage":"Calculating totals","completed":false}
id: 3
retry: 2000

event: completed
data: {"sequence":6,"percentage":100,"stage":"Report ready","completed":true}
id: 6
retry: 2000

Crear el frontend React

Podemos crear un proyecto React con TypeScript:

pnpm create vite SseDemo.React --template react-ts
cd SseDemo.React
pnpm install

No hay que instalar un cliente SSE. El hook conecta el componente con la API del navegador:

import { useEffect, useState } from 'react';

const API_URL = 'http://localhost:5187';

export type ConnectionState =
  | 'idle'
  | 'connecting'
  | 'open'
  | 'reconnecting'
  | 'closed';

export interface ReportProgress {
  sequence: number;
  percentage: number;
  stage: string;
  completed: boolean;
}

function isReportProgress(value: unknown): value is ReportProgress {
  if (typeof value !== 'object' || value === null) {
    return false;
  }

  return (
    typeof Reflect.get(value, 'sequence') === 'number' &&
    typeof Reflect.get(value, 'percentage') === 'number' &&
    typeof Reflect.get(value, 'stage') === 'string' &&
    typeof Reflect.get(value, 'completed') === 'boolean'
  );
}

function parseProgress(event: Event): ReportProgress | null {
  if (!(event instanceof MessageEvent) || typeof event.data !== 'string') {
    return null;
  }

  try {
    const value: unknown = JSON.parse(event.data);
    return isReportProgress(value) ? value : null;
  } catch {
    return null;
  }
}

export function useReportProgress(reportId: string | null) {
  const [progress, setProgress] = useState<ReportProgress | null>(null);
  const [connection, setConnection] = useState<ConnectionState>('idle');

  useEffect(() => {
    if (reportId === null) {
      setProgress(null);
      setConnection('idle');
      return;
    }

    setConnection('connecting');

    const source = new EventSource(
      `${API_URL}/api/reports/${reportId}/events`,
    );

    const onProgress: EventListener = (event) => {
      const next = parseProgress(event);
      if (next !== null) {
        setProgress(next);
      }
    };

    const onCompleted: EventListener = (event) => {
      const next = parseProgress(event);
      if (next !== null) {
        setProgress(next);
      }

      setConnection('closed');
      source.close();
    };

    source.addEventListener('progress', onProgress);
    source.addEventListener('completed', onCompleted);
    source.onopen = () => setConnection('open');
    source.onerror = () => {
      setConnection(
        source.readyState === EventSource.CONNECTING
          ? 'reconnecting'
          : 'closed',
      );
    };

    return () => {
      source.removeEventListener('progress', onProgress);
      source.removeEventListener('completed', onCompleted);
      source.close();
    };
  }, [reportId]);

  return { progress, connection };
}

El cleanup es obligatorio. React ejecuta un ciclo adicional setup → cleanup → setup en Strict Mode durante desarrollo para encontrar efectos que no se desmontan correctamente. Si olvidamos source.close(), podemos ver dos conexiones abiertas y eventos duplicados.

Otro detalle es onerror: no significa necesariamente que el stream terminó. Mientras readyState sea CONNECTING, EventSource está intentando reconectarse. Por eso la interfaz muestra reconnecting en lugar de tratar cada error como fatal.

Cuando llega completed, sí cerramos el stream de forma explícita. El trabajo terminó y mantener la conexión abierta no aporta nada.

Mostrar el progreso

El componente inicia el reporte mediante HTTP y entrega el identificador al hook.

Interfaz React mostrando el progreso recibido mediante Server-Sent Events

La interfaz real durante el stream: React mantiene una sola conexión, actualiza el porcentaje y conserva el historial de eventos recibido.

El componente queda así:

import { useState } from 'react';
import { useReportProgress } from './useReportProgress';

const API_URL = 'http://localhost:5187';

interface ReportCreated {
  id: string;
}

function isReportCreated(value: unknown): value is ReportCreated {
  return (
    typeof value === 'object' &&
    value !== null &&
    typeof Reflect.get(value, 'id') === 'string'
  );
}

export default function App() {
  const [reportId, setReportId] = useState<string | null>(null);
  const [starting, setStarting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const { progress, connection } = useReportProgress(reportId);

  async function startReport() {
    setStarting(true);
    setError(null);
    setReportId(null);

    try {
      const response = await fetch(`${API_URL}/api/reports`, {
        method: 'POST',
      });

      if (!response.ok) {
        throw new Error(`The API returned ${response.status}.`);
      }

      const value: unknown = await response.json();
      if (!isReportCreated(value)) {
        throw new Error('The API returned an invalid report identifier.');
      }

      setReportId(value.id);
    } catch (reason) {
      setError(
        reason instanceof Error
          ? reason.message
          : 'The report could not be started.',
      );
    } finally {
      setStarting(false);
    }
  }

  const isRunning = reportId !== null && !progress?.completed;

  return (
    <main>
      <p>Connection: {connection}</p>

      <button
        type="button"
        onClick={startReport}
        disabled={starting || isRunning}
      >
        {starting ? 'Starting…' : 'Generate report'}
      </button>

      {error !== null && <p role="alert">{error}</p>}

      {progress !== null && (
        <section aria-live="polite">
          <h2>{progress.stage}</h2>
          <progress max={100} value={progress.percentage} />
          <p>{progress.percentage}%</p>
        </section>
      )}
    </main>
  );
}

Interfaz React con el reporte completado al cien por ciento

Al recibir completed, la UI llega al 100 %, conserva el último evento y cierra explícitamente EventSource.

La separación es útil:

  • fetch envía el comando.
  • EventSource recibe los eventos.
  • El hook controla el ciclo de vida de la conexión.
  • El componente solo renderiza estado.

También validamos el JSON antes de guardarlo. TypeScript no convierte una respuesta externa en un tipo confiable solo porque escribamos una interfaz.

Qué ocurre cuando la red se corta

El navegador recuerda el último campo id procesado. Al reconectar, envía ese valor en el header Last-Event-ID.

Nuestro endpoint lo convierte a una secuencia:

string? lastEventId =
    context.Request.Headers["Last-Event-ID"].FirstOrDefault();

long afterSequence = long.TryParse(lastEventId, out long parsed)
    ? parsed
    : 0;

Después, ReportJobStore entrega únicamente los eventos cuya secuencia es mayor.

Emitir un id sin guardar historial no resuelve la recuperación. El navegador puede decir “el último que vi fue el 3”, pero el servidor necesita conservar o reconstruir los eventos 4, 5 y 6. En este ejemplo viven en memoria; en producción podrían persistirse en Redis, una base de datos o un event log.

sequenceDiagram
    accTitle: Reconexión con Last-Event-ID
    accDescr: El navegador reconecta después de un corte y el servidor reenvía únicamente los eventos faltantes.
    participant R as React EventSource
    participant A as ASP.NET Core
    R->>A: GET /events
    A-->>R: id 1 · progress 15%
    A-->>R: id 2 · progress 35%
    A-->>R: id 3 · progress 55%
    A--xR: conexión interrumpida
    Note right of R: espera retry 2000 ms
    R->>A: reconecta · Last-Event-ID 3
    A-->>R: replay id 4 · 70%
    A-->>R: replay id 5 · 85%
    A-->>R: replay id 6 · completed 100%

La clave es que el segundo stream no empieza desde cero: el cursor viaja en Last-Event-ID y el servidor filtra el historial.

Autenticación: la limitación de EventSource

El constructor estándar acepta una URL y la opción withCredentials. No acepta un objeto de headers:

const source = new EventSource('/api/reports/123/events', {
  withCredentials: true,
});

Para una aplicación web, la opción más limpia suele ser:

  1. Servir React y la API bajo el mismo sitio.
  2. Autenticar con una cookie HttpOnly.
  3. Autorizar el endpoint SSE igual que cualquier otro endpoint.

Si frontend y API están en orígenes distintos, hay que permitir el origen exacto, habilitar credenciales en CORS y usar withCredentials: true.

Evitaría colocar access tokens duraderos en la query string: pueden terminar en logs, historial o herramientas de observabilidad. Si el sistema exige un bearer token en Authorization, hay que usar un cliente basado en fetch/ReadableStream, una librería que permita headers o reconsiderar SignalR.

Consideraciones de producción

El ejemplo funciona en una instancia. Antes de llevarlo a producción revisaría estos puntos:

1. Buffering y compresión

Un proxy que bufferiza convierte el “tiempo real” en grupos de mensajes. Desactiva el buffering para la ruta SSE y comprueba el comportamiento extremo a extremo, no solo contra Kestrel.

2. Heartbeats

Nuestro reporte emite datos constantemente. Un stream que puede quedar inactivo durante minutos debería enviar un heartbeat periódico para que proxies y balanceadores no lo cierren por inactividad.

Puede ser un evento específico:

new SseItem<string>("ping", "heartbeat");

3. Escalado horizontal

El diccionario en memoria no sirve si una petición inicia el trabajo en la instancia A y la conexión SSE llega a la instancia B. Las alternativas son:

  • Afinidad de sesión como solución temporal.
  • Estado compartido y pub/sub con Redis.
  • Una cola o broker para distribuir eventos.
  • Persistir el historial y consumirlo desde cualquier instancia.

4. Una conexión por página, no por widget

En lugar de abrir un EventSource para cada tarjeta del dashboard, conviene multiplexar varios tipos de evento en un mismo stream y distribuirlos en React.

5. Finalización y limpieza

El servidor debe detectar desconexiones mediante RequestAborted, y el cliente debe ejecutar close() al desmontarse o al completar el trabajo. El estado final debe conservarse el tiempo suficiente para una reconexión tardía.

6. Backpressure

SSE no evita que un productor genere datos más rápido de lo que el cliente puede procesar. Para métricas muy frecuentes conviene agrupar, muestrear o descartar estados intermedios. Un porcentaje de progreso no necesita cientos de mensajes por segundo.

Probar el stream sin React

Primero iniciamos la API:

dotnet run --urls http://localhost:5187

Después creamos un reporte:

curl -X POST http://localhost:5187/api/reports

Con el id de la respuesta abrimos el stream. -N desactiva el buffering de salida de curl:

curl -N http://localhost:5187/api/reports/<id>/events

También podemos simular una reconexión:

curl -N \
  -H "Last-Event-ID: 3" \
  http://localhost:5187/api/reports/<id>/events

La respuesta debe comenzar en el evento 4, no desde cero.

Cuándo no usaría SSE

No elegiría SSE si:

  • El cliente debe enviar mensajes continuamente por el mismo canal.
  • Necesitamos datos binarios.
  • El protocolo requiere acknowledgements complejos por mensaje.
  • Cada usuario mantiene muchas conexiones independientes.
  • La autenticación exige headers personalizados y no queremos otro cliente.
  • Ya usamos SignalR y sus hubs, grupos y reconexión resuelven el caso.

Pero para progreso de trabajos, notificaciones, logs, métricas, feeds y streaming de texto, SSE suele ser una solución más pequeña y suficientemente robusta.

Conclusión

Server-Sent Events ocupa un espacio útil entre polling y WebSockets.

.NET 10 elimina gran parte del trabajo mecánico con TypedResults.ServerSentEvents y SseItem<T>. React puede consumir el resultado con una API nativa del navegador, sin SDK adicional.

La parte difícil no es abrir el stream. Es decidir qué ocurre cuando la conexión se corta, dónde vive el historial, cómo se autentica, qué capa puede bufferizar la respuesta y cómo se distribuyen los eventos al escalar.

Si esas decisiones están claras, el patrón queda simple:

POST para ordenar trabajo.
SSE para observarlo.
Event IDs para recuperarlo.
close() cuando termina.

Fuentes


Ejemplo validado con .NET SDK 10.0.110, React 19.2, TypeScript 7.0.2 y una prueba real de reanudación mediante Last-Event-ID.

Comentarios

Cargando comentarios…