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:
- React sends a
POSTto start the work. - The API immediately responds with an identifier.
- The browser opens a stream for that job.
- .NET sends the percentage and current stage.
- 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-streamtype. - The server can assign a name to each event.
- The browser attempts to reconnect if the connection is interrupted.
- The
idfield 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.ApiThe 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:
- The SSE endpoint returns
TypedResults.ServerSentEvents. RequestAbortedcancels the enumerator when the browser closes the connection.Cache-Control: no-cacheprevents an intermediate layer from treating the stream as a reusable response.X-Accel-Buffering: nodisables buffering when the proxy is Nginx.- The server reads
Last-Event-IDto 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
TaskCompletionSourcewakes 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:progressorcompleted.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: 2000Create the React frontend
We can create a React project with TypeScript:
pnpm create vite SseDemo.React --template react-ts
cd SseDemo.React
pnpm installThere 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.
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>
);
}When completed is received, the UI reaches 100%, retains the last event, and explicitly closes EventSource.
The separation is useful:
fetchsends the command.EventSourcereceives 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:
- Serve React and the API under the same site.
- Authenticate with an
HttpOnlycookie. - 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:5187Then we create a report:
curl -X POST http://localhost:5187/api/reportsUsing the id from the response, we open the stream. -N disables curl output buffering:
curl -N http://localhost:5187/api/reports/<id>/eventsWe can also simulate a reconnection:
curl -N \
-H "Last-Event-ID: 3" \
http://localhost:5187/api/reports/<id>/eventsThe 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
- TypedResults.ServerSentEvents — Microsoft Learn
SseItem<T>— Microsoft Learn- ASP.NET Core in .NET 10 Preview 3: Support for Server-Sent Events
- EventSource — HTML Living Standard
- Using server-sent events — MDN
- React useEffect — React documentation
- CORS in ASP.NET Core — Microsoft Learn
Example validated with .NET SDK 10.0.110, React 19.2, TypeScript 7.0.2, and a real resumption test using Last-Event-ID.