Streaming SSE from ASP.NET Core Minimal APIs Permalink to this section
Part of ASP.NET Core SSE Implementation, under Backend Stream Generation & Connection Management.
A minimal API endpoint can serve a Server-Sent Events stream in a handful of lines. The details that make it production-ready — event ids for resume, named events, a retry hint, flushing so nothing sits in a buffer, and correct JSON on one data: line — are where first attempts go wrong. This guide builds the same endpoint two ways: with .NET 10’s built-in TypedResults.ServerSentEvents, and with a manual writer that works on every ASP.NET Core version and gives full control.
Symptom & Developer Intent Permalink to this section
First implementations tend to show one of these:
curl -Nshows nothing for a while, then many events at once.- The browser’s
EventSourcefireserrorimmediately and reconnects in a loop. - Events arrive, but
onmessagenever fires for them, or fires withundefineddata. - Reconnecting clients always start from the beginning.
- Pretty-printed JSON arrives in the browser truncated to its first line.
The intent is a minimal API endpoint that streams named, identified JSON events immediately, advertises a retry interval, and resumes from Last-Event-ID.
Root Cause Analysis Permalink to this section
Each symptom maps to one missing piece of the protocol:
Buffering is the most common. HttpResponse.WriteAsync writes into Kestrel’s response pipe, which sends when it decides to — typically when a buffer fills or the response completes. For a stream, “completes” is never, so events must be flushed explicitly. The Content-Type matters because EventSource treats any other type as a fatal error and fails the connection. Named events (event: price) are delivered to listeners registered for that name, not to onmessage, which only receives unnamed events.
Step-by-Step Resolution Permalink to this section
Step 1 — The built-in result (.NET 10) Permalink to this section
using System.Net.ServerSentEvents;
var app = WebApplication.CreateBuilder(args).Build();
app.MapGet("/api/ticks", (CancellationToken ct) =>
TypedResults.ServerSentEvents(Ticks(ct)));
static async IAsyncEnumerable<SseItem<Tick>> Ticks(
[EnumeratorCancellation] CancellationToken ct)
{
long id = 0;
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(ct))
{
id++;
yield return new SseItem<Tick>(new Tick(id, DateTimeOffset.UtcNow), eventType: "tick")
{
EventId = id.ToString(),
ReconnectionInterval = TimeSpan.FromSeconds(3), // emitted as retry: 3000
};
}
}
record Tick(long N, DateTimeOffset At);
app.Run();
The result sets the content type, serialises each item’s data as JSON, writes id:, event: and retry: fields when present, and flushes after every item.
Step 2 — The manual writer (any version) Permalink to this section
app.MapGet("/api/ticks-manual", async (HttpContext ctx, CancellationToken ct) =>
{
ctx.Response.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers["X-Accel-Buffering"] = "no";
// Disable any response buffering a server or middleware might add.
ctx.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering();
await ctx.Response.WriteAsync("retry: 3000\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
long.TryParse(ctx.Request.Headers["Last-Event-ID"], out var id); // resume point
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(ct))
{
id++;
var json = JsonSerializer.Serialize(new Tick(id, DateTimeOffset.UtcNow));
await ctx.Response.WriteAsync($"id: {id}\nevent: tick\ndata: {json}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct); // send now, not later
}
});
The manual version is also where heartbeats go: write ": hb\n\n" and flush every 15 seconds when there is nothing else to send.
Step 3 — Keep JSON on one data line Permalink to this section
JsonSerializer.Serialize produces single-line JSON by default. If the app’s global options set WriteIndented = true for readable API responses, serialise stream payloads with separate options:
static readonly JsonSerializerOptions SseJson = new(JsonSerializerDefaults.Web) { WriteIndented = false };
var json = JsonSerializer.Serialize(payload, SseJson);
If a payload can legitimately contain newlines outside JSON — plain text, logs — split it and prefix every line with data: , as described in formatting multiline data fields.
Step 4 — Listen for named events on the client Permalink to this section
const es = new EventSource('/api/ticks');
es.addEventListener('tick', (e) => { // named events do NOT reach onmessage
const tick = JSON.parse(e.data);
console.log(e.lastEventId, tick.n);
});
es.onerror = () => console.warn('reconnecting…', es.readyState);
When the endpoint needs to be discoverable in OpenAPI, document it explicitly: the generator cannot describe an infinite response usefully, so add .Produces(StatusCodes.Status200OK, contentType: "text/event-stream") and a description of the event names and payload shapes. Consumers of the API — including other teams writing non-browser clients — then know which event: names to listen for without reading the server code.
app.MapGet("/api/ticks", (CancellationToken ct) => TypedResults.ServerSentEvents(Ticks(ct)))
.WithName("StreamTicks")
.WithDescription("SSE stream. Events: 'tick' with {n, at}. Resumable via Last-Event-ID.")
.Produces(StatusCodes.Status200OK, contentType: "text/event-stream")
.DisableRequestTimeout();
Step 5 — Replay what the client missed Permalink to this section
The manual writer above continues counting from the client’s id, which is enough for a synthetic tick stream. For real events, read Last-Event-ID, query your store for newer events, send them, and only then enter the live loop — subscribing to live events before the query so nothing slips between. The full ordering is in the ASP.NET Core SSE topic.
Validation & Monitoring Permalink to this section
# Events must arrive once per second, not in bursts, and carry ids.
curl -sN http://localhost:5000/api/ticks | while IFS= read -r l; do echo "$(date +%T.%N | cut -c1-12) $l"; done
# Resume: send an id and confirm the stream continues after it.
curl -sN -H 'Last-Event-ID: 41' http://localhost:5000/api/ticks-manual | head -4
# retry: 3000
#
# id: 42
In the browser, the Network panel’s EventStream tab should list each event with its id and type as it arrives. Log stream opens and closes with the resume id and duration; a burst of opens every 30 or 60 seconds points at a proxy idle timeout that heartbeats should cover.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Does TypedResults.ServerSentEvents send heartbeats?
No. It writes the items your enumerable yields. For heartbeats, merge a periodic comment into the source, or use the manual writer where a comment line is one WriteAsync call.
Why does EventSource reconnect in a loop against my endpoint?
Usually the response has the wrong content type or a non-200 status, or the handler returns immediately so the response ends. Check the headers with curl -i and make sure the handler stays in its loop until cancellation.
Can I stream strings instead of JSON?
Yes. With the built-in result, yield SseItem<string> values and the data is written as-is; with the manual writer, write the text directly, splitting on newlines into multiple data: lines.
Is HTTP/2 handled differently?
The handler code is identical. Kestrel sends each flush as HTTP/2 DATA frames, and many streams share one connection, which removes the browser's six-connection limit for HTTP/1.1.