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:

The ASP.NET Core pieces behind one SSE stream Layers from the endpoint handler producing an IAsyncEnumerable of SseItem, through the SSE formatter, the response body pipe, and Kestrel's connection to the TCP socket. The ASP.NET Core pieces behind one SSE stream Endpoint IAsyncEnumerable of SseItem SSE formatter System.Net.ServerSentEvents Response body PipeWriter, FlushAsync Kestrel HTTP/1.1 chunked or HTTP/2 DATA Socket one connection per stream
Everything above Kestrel is asynchronous, so an idle stream is a suspended state machine rather than a blocked thread.

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.

What happens when the browser tab closes Sequence diagram of a browser closing its connection, Kestrel detecting the close and cancelling RequestAborted, the async enumerator unwinding and the hub removing the subscription. What happens when the browser tab closes Browser Kestrel Handler Hub FIN / RST_STREAM RequestAborted cancelled ReadAllAsync throws OperationCanceled finally: remove subscription
Cancellation flows from the socket to your finally block without any polling. A heartbeat makes sure the close is noticed even on an idle stream.

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 that text/event-stream is 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 via IHttpMinResponseDataRateFeature.
  • 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:

  1. 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.
  2. 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.
  3. A fetch-based client that sends Authorization like 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.

Idle memory per open stream by approach Bar chart comparing approximate managed memory per idle SSE connection for the built-in ServerSentEvents result with a bounded channel, a manual writer with a bounded channel, and an unbounded channel with a slow client. Idle memory per open stream by approach ServerSentEvents + bounded channel ~9 KB Manual writer + bounded channel ~8 KB Unbounded channel, slow client grows without limit approximate KB of managed heap per idle stream, .NET 10, Kestrel
Idle streams are cheap either way. The number to watch is the unbounded channel, whose cost is set by how far behind a slow client falls.

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:

Plain SSE versus SignalR on ASP.NET Core Matrix comparing plain Server-Sent Events and SignalR on direction, client requirements, proxy friendliness, scale-out and operational surface. Plain SSE versus SignalR on ASP.NET Core Criterion Plain SSE SignalR Client → server calls separate POSTs hub methods Client library needed none (EventSource) SignalR client Works through any HTTP proxy plain HTTP WebSockets preferred Scale-out your broker backplane or Azure SignalR Debug with curl yes protocol framing advantage neutral drawback
SignalR is a framework with a protocol; plain SSE is a response format. Pick the framework when you need its features, not by default.

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.

Deep Dives