ASP.NET Core SSE Implementation Permalink to this section
Part of Backend Stream Generation & Connection Management.
ASP.NET Core is a strong host for Server-Sent Events. Kestrel is fully asynchronous, so an open stream parked in await costs no thread; IAsyncEnumerable<T> makes a stream a natural return type; System.Threading.Channels provides bounded, backpressure-aware queues for fan-out; and every request carries a CancellationToken that fires when the client goes away. Since .NET 10 the framework also ships a first-class result type, TypedResults.ServerSentEvents, built on the System.Net.ServerSentEvents formatter. This guide covers both the built-in result and the manual approach you still need for full control, the fan-out architecture with channels, the .NET client, the hosting settings — IIS, reverse proxies, response compression — that quietly break streams, and sizing a Kestrel process for tens of thousands of connections. SignalR is the other real-time option on .NET; the comparison at the end of this page explains when plain SSE is the better fit.
How It Works Permalink to this section
An ASP.NET Core SSE endpoint is a request handler that sets Content-Type: text/event-stream, writes frames to HttpResponse.Body, flushes after each one, and keeps going until the request is aborted. The pieces of the framework that matter map directly onto the protocol:
With .NET 10’s built-in result, the handler returns an async stream of SseItem<T> and the framework handles headers, formatting and flushing:
// Program.cs — .NET 10 minimal API
app.MapGet("/api/prices/stream", (PriceHub hub, CancellationToken ct) =>
TypedResults.ServerSentEvents(hub.Subscribe(ct), eventType: "price"));
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
event: price
data: {"sym":"ACME","bid":101.2}
event: price
data: {"sym":"ACME","bid":101.21}
Each item is serialised to JSON with the app’s configured serializer options, and the CancellationToken bound from the request is HttpContext.RequestAborted, which fires when the client disconnects. To set ids and per-item event names, yield SseItem<T> values instead of plain objects.
On earlier versions — and when you need heartbeats, custom retry values or precise control over flushing — write the frames yourself. It is only a few lines longer, and streaming SSE from ASP.NET Core minimal APIs develops both versions side by side.
Server-Side Implementation Permalink to this section
The production shape is a hub that owns subscriptions and a handler per connection. Each subscriber gets a bounded Channel<T>; the hub writes into every channel; each handler reads its own channel and writes to its own response.
// PriceHub.cs — one bounded channel per subscriber, fan-out without blocking the publisher.
public sealed class PriceHub
{
private readonly ConcurrentDictionary<Guid, Channel<SseItem<Price>>> _subs = new();
private long _seq;
public async IAsyncEnumerable<SseItem<Price>> Subscribe(
[EnumeratorCancellation] CancellationToken ct)
{
var ch = Channel.CreateBounded<SseItem<Price>>(new BoundedChannelOptions(256)
{
FullMode = BoundedChannelFullMode.DropOldest, // state-shaped data: keep the newest
SingleReader = true,
});
var id = Guid.NewGuid();
_subs[id] = ch;
try
{
await foreach (var item in ch.Reader.ReadAllAsync(ct))
yield return item;
}
finally
{
_subs.TryRemove(id, out _); // runs on disconnect (ct cancelled)
}
}
public void Publish(Price p)
{
var item = new SseItem<Price>(p, "price")
{
EventId = Interlocked.Increment(ref _seq).ToString(),
};
foreach (var ch in _subs.Values)
ch.Writer.TryWrite(item); // never blocks the publisher
}
}
DropOldest is the right overflow policy for prices and dashboards, where only the newest value matters. For notifications, use BoundedChannelFullMode.Wait with a short timeout in Publish, or better, complete the channel with an error so the stream ends and the client reconnects and replays — dropping notifications silently is worse than a reconnect.
The channel’s FullMode is the backpressure policy for that subscriber, so it deserves a deliberate choice per stream type:
BoundedChannelFullMode |
Effect on a slow subscriber | Use for |
|---|---|---|
DropOldest |
loses the oldest queued item, keeps the newest | prices, dashboards, presence |
DropNewest / DropWrite |
loses the item being written | rarely useful for streams |
Wait |
WriteAsync waits for space |
only with a timeout, never in a broadcast loop |
| complete the writer | ends that stream; client reconnects and replays | notifications, audit feeds, anything that must not drop |
Whatever the mode, keep capacity small — tens to a few hundred items. A large buffer does not help a client that is persistently slower than the feed; it only delays the moment it has to be dealt with and holds memory meanwhile. The general reasoning is in dropping vs coalescing events under backpressure.
The endpoint registers the hub as a singleton and feeds it from wherever prices come from: a hosted service reading a message broker, a gRPC stream, or domain events.
builder.Services.AddSingleton<PriceHub>();
builder.Services.AddHostedService<PriceFeedWorker>(); // reads the broker, calls hub.Publish
app.MapGet("/api/prices/stream", (PriceHub hub, HttpContext ctx, CancellationToken ct) =>
{
ctx.Response.Headers["X-Accel-Buffering"] = "no"; // for nginx in front
return TypedResults.ServerSentEvents(hub.Subscribe(ct));
});
Heartbeats and Last-Event-ID Permalink to this section
Idle streams need a comment line every 15–30 seconds, and reconnecting clients send Last-Event-ID. The manual writer handles both directly:
app.MapGet("/api/notifications/stream", async (HttpContext ctx, NotificationStore store,
NotificationHub hub, CancellationToken ct) =>
{
ctx.Response.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers["X-Accel-Buffering"] = "no";
var user = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
long.TryParse(ctx.Request.Headers["Last-Event-ID"], out var cursor);
var live = hub.Subscribe(user, ct); // subscribe first…
await ctx.Response.WriteAsync("retry: 5000\n\n", ct);
foreach (var n in await store.AfterAsync(user, cursor, ct)) // …then replay
{
await WriteEvent(ctx.Response, n.Id, "notification", n, ct);
cursor = n.Id;
}
await ctx.Response.Body.FlushAsync(ct);
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(15));
var next = live.GetAsyncEnumerator(ct);
var itemTask = next.MoveNextAsync().AsTask();
var beatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
while (!ct.IsCancellationRequested)
{
var done = await Task.WhenAny(itemTask, beatTask);
if (done == beatTask)
{
await ctx.Response.WriteAsync(": hb\n\n", ct);
beatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
}
else
{
if (!await itemTask) break;
var n = next.Current;
if (n.Id > cursor) { await WriteEvent(ctx.Response, n.Id, "notification", n, ct); cursor = n.Id; }
itemTask = next.MoveNextAsync().AsTask();
}
await ctx.Response.Body.FlushAsync(ct);
}
});
static Task WriteEvent<T>(HttpResponse res, long id, string type, T payload, CancellationToken ct) =>
res.WriteAsync($"id: {id}\nevent: {type}\ndata: {JsonSerializer.Serialize(payload)}\n\n", ct);
JsonSerializer.Serialize never emits raw newlines unless indentation is enabled, so the payload stays on one data: line. If you serialise with WriteIndented = true, every line must be prefixed with data: , as the multiline data field rules describe.
Client-Side Consumption Permalink to this section
Browsers use EventSource as with any server. For .NET clients — a background service consuming another service’s stream, or a MAUI app — .NET 9 and later include SseParser in System.Net.ServerSentEvents:
using System.Net.ServerSentEvents;
var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; // streams outlive 100 s
string? lastId = null;
while (!stopping.IsCancellationRequested)
{
try
{
using var req = new HttpRequestMessage(HttpMethod.Get, "https://prices.example.com/api/prices/stream");
req.Headers.Accept.ParseAdd("text/event-stream");
if (lastId is not null) req.Headers.Add("Last-Event-ID", lastId);
using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, stopping);
res.EnsureSuccessStatusCode();
await using var body = await res.Content.ReadAsStreamAsync(stopping);
await foreach (var item in SseParser.Create(body).EnumerateAsync(stopping))
{
lastId = item.EventId ?? lastId;
Handle(item.EventType, item.Data);
}
}
catch (HttpRequestException) { /* fall through to backoff */ }
await Task.Delay(Backoff.Next(), stopping);
}
Two settings are essential: HttpCompletionOption.ResponseHeadersRead, without which SendAsync tries to buffer the whole (infinite) body; and an infinite HttpClient.Timeout, since the default of 100 seconds applies to the whole response. More client runtimes are covered in non-browser SSE clients.
Edge Cases & Network Interference Permalink to this section
- Response compression.
app.UseResponseCompression()compresses matching MIME types. Check thattext/event-streamis not in your configured list; if it is compressed, events are held in the compressor until a block fills. - IIS and the ASP.NET Core Module. Out-of-process hosting proxies through ANCM, which forwards responses as they are flushed; in-process hosting under IIS may buffer if dynamic compression is enabled for the site. Disable IIS dynamic compression for the stream path.
- Kestrel’s minimum data rates.
MinResponseDataRate(240 bytes/second after a 5-second grace period by default) can abort connections to clients that are slow to accept data while a write is pending. For streams to mobile clients, relax it per request viaIHttpMinResponseDataRateFeature. - Request timeouts middleware. .NET 8 added
RequestTimeouts; a global policy will cancel long streams. Exclude the endpoint with.DisableRequestTimeout(). - Load balancers. Azure Application Gateway, Front Door and App Service each impose idle timeouts in the 230–240 second range on some tiers. Heartbeats under 30 seconds make them irrelevant.
Authentication on a stream EventSource opens Permalink to this section
Browsers’ EventSource cannot set an Authorization header, which surprises teams whose APIs use bearer tokens. Three options work with ASP.NET Core’s authentication handlers:
- Cookie authentication on the same site.
new EventSource(url, { withCredentials: true })sends cookies, and[Authorize]works unchanged. This is the simplest option for first-party web apps. - A short-lived stream ticket. The client calls an authenticated endpoint to obtain a single-use token valid for thirty seconds, then opens
/stream?ticket=…. A small authentication handler validates and burns the ticket. Tokens in URLs end up in logs, which is why the ticket must be short-lived and single-use. - A fetch-based client that sends
Authorizationlike any other request, at the cost of reimplementing reconnection; see adding auth headers to SSE requests.
// JwtBearer reading the token from the query string for the stream path only.
builder.Services.AddAuthentication().AddJwtBearer(o =>
{
o.Events = new JwtBearerEvents
{
OnMessageReceived = ctx =>
{
if (ctx.Request.Path.StartsWithSegments("/api/stream") &&
ctx.Request.Query.TryGetValue("access_token", out var t))
ctx.Token = t; // same pattern SignalR uses
return Task.CompletedTask;
},
};
});
Authentication runs once, at connect. A token that expires an hour later does not end the stream by itself; if that matters, end the stream at the token’s expiry so the reconnect re-authenticates.
Mitigation checklist:
Performance & Scale Considerations Permalink to this section
Kestrel holds idle streams cheaply: a suspended async state machine, a pipe buffer and a socket. The practical limits are connection count and memory.
Settings to review for a streaming service:
builder.WebHost.ConfigureKestrel(k =>
{
k.Limits.MaxConcurrentConnections = 50_000;
k.Limits.MaxConcurrentUpgradedConnections = null; // not used by SSE
k.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
k.Limits.Http2.MaxStreamsPerConnection = 200; // many streams share one H2 connection
});
Use server GC with container memory limits set properly, and raise the process file descriptor limit on Linux. For multiple instances, feed each hub from a broker — Redis, NATS, Azure Service Bus topics or Event Hubs — rather than in-process publishing, as in Redis pub/sub fan-out. With StackExchange.Redis the relay is a hosted service with one subscription per instance:
public sealed class RedisPriceRelay(IConnectionMultiplexer redis, PriceHub hub) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var queue = await redis.GetSubscriber()
.SubscribeAsync(RedisChannel.Literal("prices")); // one subscription per instance
queue.OnMessage(msg =>
{
var price = JsonSerializer.Deserialize<Price>((string)msg.Message!)!;
hub.Publish(price); // local fan-out via channels
});
await Task.Delay(Timeout.Infinite, stoppingToken);
}
}
Each instance receives every price once and multiplies it locally across its own subscribers, so Redis load is proportional to instance count, not to connected clients.
SSE or SignalR? SignalR adds a bidirectional hub protocol, groups, and automatic transport negotiation (WebSockets, then SSE, then long polling). The trade-off in practice:
Choose SignalR when clients call server methods frequently or you need its client libraries across platforms. Choose plain SSE when traffic is server-to-client, clients are browsers or simple HTTP clients, and you want standard HTTP semantics, caching layers and tooling without a protocol on top.
Validation & Debugging Permalink to this section
# Stream stays open well past any default timeout, frames arrive as published.
curl -sN http://localhost:5000/api/prices/stream | while IFS= read -r l; do echo "$(date +%T) $l"; done
# Live counters: connections and thread pool while streams are open.
dotnet-counters monitor -n MyService Microsoft.AspNetCore.Hosting Microsoft-AspNetCore-Server-Kestrel System.Runtime
In dotnet-counters, current-connections should track open streams while threadpool-thread-count stays flat — if thread count climbs with streams, something is blocking synchronously (a .Result, a synchronous Write). An integration test with WebApplicationFactory reads the first events from the test server’s response stream and asserts on their ids and payloads. The in-memory test server streams responses, so the same SseParser used by production clients works in tests:
public class PriceStreamTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Streams_published_prices_with_ids()
{
var client = factory.CreateClient();
using var res = await client.GetAsync("/api/prices/stream", HttpCompletionOption.ResponseHeadersRead);
Assert.Equal("text/event-stream", res.Content.Headers.ContentType?.MediaType);
var hub = factory.Services.GetRequiredService<PriceHub>();
hub.Publish(new Price("ACME", 101.2m));
await using var body = await res.Content.ReadAsStreamAsync();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await foreach (var item in SseParser.Create(body).EnumerateAsync(cts.Token))
{
Assert.Equal("price", item.EventType);
Assert.False(string.IsNullOrEmpty(item.EventId));
break; // one event is enough
}
}
}
Publish after the response headers arrive, so the subscription exists before the event is sent; otherwise the test races the subscription and fails intermittently.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Do I need .NET 10 to serve SSE?
No. Any ASP.NET Core version can stream SSE by setting the content type and writing and flushing frames. .NET 10's TypedResults.ServerSentEvents removes the boilerplate for the common case.
How do I detect that the client disconnected?
Observe HttpContext.RequestAborted, which is also the CancellationToken bound to minimal API handlers. It is cancelled when Kestrel sees the connection close, and your async enumeration throws OperationCanceledException, running any finally blocks.
Why are my events delayed until several arrive?
Something is buffering: response compression, IIS dynamic compression, a proxy, or a missing FlushAsync in a manual writer. The built-in result flushes per item; with a manual writer, flush after every event.
Can controllers return SSE as well as minimal APIs?
Yes. A controller action can return the same ServerSentEvents result, or write to Response.Body directly and return an EmptyResult. The cancellation token is available as HttpContext.RequestAborted or as an action parameter.
How many streams can one Kestrel instance hold?
Tens of thousands on a modest container when streams are idle most of the time. Memory per stream is a few kilobytes; the practical ceilings are MaxConcurrentConnections, the process file descriptor limit and how much work each broadcast does per subscriber.
Should I use SignalR instead?
Use SignalR when you need frequent client-to-server calls, groups managed by the framework, or its cross-platform clients. For one-way feeds to browsers, plain SSE is simpler to operate and works through more intermediaries unchanged.