← Back to the blogServer-Sent Events with .NET 10 and React: Real-Time Progress Without WebSockets
dotnetaspnet-corereactserver-sent-eventsreal-time

Server-Sent Events with .NET 10 and React: Real-Time Progress Without WebSockets

We built an application that generates reports in the background and streams their progress from a .NET 10 Minimal API to React using Server-Sent Events, with typed events, automatic reconnection, and resumption using Last-Event-ID.

We built an application that generates reports in the background and streams their progress from a .NET 10 Minimal API to React using Server-Sent Events, with typed events, automatic reconnection, and resumption using Last-Event-ID.

When the browser only needs to receive updates from the server, a bidirectional connection can be more infrastructure than necessary. SSE keeps an HTTP request open and lets the backend send events as they happen.


The phrase “real-time updates” often leads us directly to WebSockets or SignalR. They are good tools, but not every problem requires communication in both directions.

A report generator is a good example:

  1. React sends a POST to start the work.
  2. The API immediately responds with an identifier.
  3. The browser opens a stream for that job.
  4. .NET sends the percentage and current stage.
  5. React updates the interface until it receives the completion event.

Commands continue to use regular HTTP. The persistent channel is used only for the server → client flow.

In .NET 10, this pattern is more straightforward because ASP.NET Core includes TypedResults.ServerSentEvents. We no longer need to manually write every event:, data:, id:, and retry: line in the protocol.

What we are going to build

The application will have two endpoints:

flowchart LR
    accTitle: SSE application architecture
    accDescr: React starts a report, the API registers the job, and the SSE endpoint streams progress.
    React["React · interface"] -->|"POST /api/reports"| Start["Minimal API · start job"]
    Start -->|"202 Accepted · id"| React
    Start --> Store["ReportJobStore · history"]
    React -->|"GET /api/reports/{id}/events"| Stream["TypedResults.ServerSentEvents"]
    Store -->|"IAsyncEnumerable of SseItem"| Stream
    Stream -->|"progress · id · retry"| React

The complete flow looks like this:

sequenceDiagram
    accTitle: Complete flow of a report with SSE
    accDescr: React starts the report, opens EventSource, and receives events until the job is complete.
    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 while working
      J-->>A: ReportProgress
      A-->>R: event progress · id n
    end
    A-->>R: event completed · 100%
    deactivate A
    R->>R: source.close()

Animation of the complete flow: job creation, opening EventSource, network interruption, reconnection with Last-Event-ID: 3, and exclusive replay of the missing events.

We will not install a real-time library in React. EventSource is part of the web platform, and modern browsers implement automatic reconnection.

SSE, WebSockets, or SignalR

The choice depends on the direction and complexity of the communication:

Need Best starting point
Progress, notifications, metrics, logs, or text streaming from the server SSE
Frequent messages in both directions or binary content WebSockets
Hubs, groups, client-server invocation, and a complete abstraction in .NET SignalR
Infrequent updates where a delay of a few seconds is acceptable Polling

SSE has four useful properties for this case:

  • It works over HTTP and uses the text/event-stream type.
  • The server can assign a name to each event.
  • The browser attempts to reconnect if the connection is interrupted.
  • The id field allows it to continue from the last event received.

It also has clear limitations: the channel is unidirectional, messages are text, and the native EventSource API does not allow adding an arbitrary Authorization header.

What .NET 10 provides

ASP.NET Core 10 added three overloads of TypedResults.ServerSentEvents:

  • A stream of strings.
  • An IAsyncEnumerable<T> with a common event type.
  • An IAsyncEnumerable<SseItem<T>> to control the type, identifier, and reconnection time of each event.

We will use the third option:

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

Objects are serialized as JSON using the options configured in ASP.NET Core. For strings, the result writes the content without additional serialization.

Create the API

We start with a minimal web application:

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

The contracts contain the identifier returned by the POST and the state sent over SSE:

public sealed record ReportCreated(Guid Id);

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

The endpoints in Program.cs

The API configures CORS for the React development server, starts jobs through POST, and exposes the stream through 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();

There are several important details:

  1. The SSE endpoint returns TypedResults.ServerSentEvents.
  2. RequestAborted cancels the enumerator when the browser closes the connection.
  3. Cache-Control: no-cache prevents an intermediate layer from treating the stream as a reusable response.
  4. X-Accel-Buffering: no disables buffering when the proxy is Nginx.
  5. The server reads Last-Event-ID to determine the sequence from which it should resume.

The X-Accel-Buffering header is specific to Nginx. With another proxy, you need to check its equivalent configuration; what matters is that it does not accumulate several events before sending them.

Store progress and allow replay

The example store retains the events for each job for ten minutes:

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

The implementation uses two ideas:

  • A list keeps the history required to resume from an identifier.
  • A TaskCompletionSource wakes all subscribers when an event appears.

The latter is intentional. A single Channel<T> with multiple readers distributes items among consumers; it does not broadcast them to everyone. If two tabs observed the same report, they could receive different events. To turn Channels into broadcast, we would need one channel per subscriber or a pub/sub layer.

Each SseItem<ReportProgress> includes:

  • EventType: progress or completed.
  • EventId: the event sequence.
  • ReconnectionInterval: two seconds.
  • Data: the object that ASP.NET Core serializes to JSON.

The actual result on the network looks like this:

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

Create the React frontend

We can create a React project with TypeScript:

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

There is no need to install an SSE client. The hook connects the component to the browser API:

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 };
}

Cleanup is mandatory. React runs an additional setup → cleanup → setup cycle in Strict Mode during development to find effects that do not unmount correctly. If we forget source.close(), we may see two open connections and duplicate events.

Another detail is onerror: it does not necessarily mean that the stream has ended. While readyState is CONNECTING, EventSource is attempting to reconnect. That is why the interface shows reconnecting instead of treating every error as fatal.

When completed arrives, we do explicitly close the stream. The job has finished, and keeping the connection open provides no benefit.

Display progress

The component starts the report over HTTP and passes the identifier to the hook.

React interface showing progress received through Server-Sent Events

The actual interface during the stream: React maintains a single connection, updates the percentage, and retains the history of received events.

The component looks like this:

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

React interface showing the report completed at one hundred percent

When completed is received, the UI reaches 100%, retains the last event, and explicitly closes EventSource.

The separation is useful:

  • fetch sends the command.
  • EventSource receives the events.
  • The hook controls the connection lifecycle.
  • The component only renders state.

We also validate the JSON before storing it. TypeScript does not turn an external response into a trusted type just because we write an interface.

What happens when the network is interrupted

The browser remembers the last id field it processed. When reconnecting, it sends that value in the Last-Event-ID header.

Our endpoint converts it to a sequence:

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

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

Then, ReportJobStore returns only the events whose sequence is greater.

Emitting an id without storing history does not solve recovery. The browser can say “the last one I saw was 3,” but the server needs to retain or reconstruct events 4, 5, and 6. In this example they live in memory; in production they could be persisted in Redis, a database, or an event log.

sequenceDiagram
    accTitle: Reconnection with Last-Event-ID
    accDescr: The browser reconnects after an interruption, and the server resends only the missing events.
    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: connection interrupted
    Note right of R: wait retry 2000 ms
    R->>A: reconnect · Last-Event-ID 3
    A-->>R: replay id 4 · 70%
    A-->>R: replay id 5 · 85%
    A-->>R: replay id 6 · completed 100%

The key is that the second stream does not start from zero: the cursor travels in Last-Event-ID, and the server filters the history.

Authentication: the EventSource limitation

The standard constructor accepts a URL and the withCredentials option. It does not accept a headers object:

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

For a web application, the cleanest option is usually:

  1. Serve React and the API under the same site.
  2. Authenticate with an HttpOnly cookie.
  3. Authorize the SSE endpoint like any other endpoint.

If the frontend and API are on different origins, you must allow the exact origin, enable credentials in CORS, and use withCredentials: true.

I would avoid placing long-lived access tokens in the query string: they can end up in logs, history, or observability tools. If the system requires a bearer token in Authorization, you need to use a client based on fetch/ReadableStream, a library that allows headers, or reconsider SignalR.

Production considerations

The example works on one instance. Before taking it to production, I would review these points:

1. Buffering and compression

A buffering proxy turns “real time” into batches of messages. Disable buffering for the SSE route and verify end-to-end behavior, not just behavior against Kestrel.

2. Heartbeats

Our report emits data constantly. A stream that may remain idle for minutes should send a periodic heartbeat so that proxies and load balancers do not close it due to inactivity.

It can be a specific event:

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

3. Horizontal scaling

The in-memory dictionary does not work if a request starts the job on instance A and the SSE connection reaches instance B. The alternatives are:

  • Session affinity as a temporary solution.
  • Shared state and pub/sub with Redis.
  • A queue or broker to distribute events.
  • Persisting history and consuming it from any instance.

4. One connection per page, not per widget

Instead of opening an EventSource for every dashboard card, it is better to multiplex several event types in a single stream and distribute them in React.

5. Completion and cleanup

The server must detect disconnections through RequestAborted, and the client must call close() when unmounting or when the job completes. The final state must be retained long enough for a late reconnection.

6. Backpressure

SSE does not prevent a producer from generating data faster than the client can process it. For very frequent metrics, it is better to batch, sample, or discard intermediate states. A progress percentage does not need hundreds of messages per second.

Test the stream without React

First, we start the API:

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

Then we create a report:

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

Using the id from the response, we open the stream. -N disables curl output buffering:

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

We can also simulate a reconnection:

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

The response should begin at event 4, not from zero.

When I would not use SSE

I would not choose SSE if:

  • The client must continuously send messages over the same channel.
  • We need binary data.
  • The protocol requires complex acknowledgements per message.
  • Each user maintains many independent connections.
  • Authentication requires custom headers and we do not want another client.
  • We already use SignalR, and its hubs, groups, and reconnection solve the use case.

But for job progress, notifications, logs, metrics, feeds, and text streaming, SSE is often a smaller and sufficiently robust solution.

Conclusion

Server-Sent Events occupies a useful space between polling and WebSockets.

.NET 10 removes much of the mechanical work with TypedResults.ServerSentEvents and SseItem<T>. React can consume the result through a native browser API, without an additional SDK.

The hard part is not opening the stream. It is deciding what happens when the connection is interrupted, where history lives, how authentication works, which layer may buffer the response, and how events are distributed when scaling.

If those decisions are clear, the pattern remains simple:

POST to order the work.
SSE to observe it.
Event IDs to recover it.
close() when it finishes.

Sources


Example validated with .NET SDK 10.0.110, React 19.2, TypeScript 7.0.2, and a real resumption test using Last-Event-ID.

Comments

Loading comments…