Server-Sent Events (.NET 10)

Stream one-way updates to browsers and services with TypedResults.ServerSentEvents, SseItem ids for resuming, and clean cancellation on disconnect.

Syntaxapp.MapGet("/orders/{id:int}/events", (int id, OrderFeed feed, CancellationToken ct) => TypedResults.ServerSentEvents(feed.Watch(id, ct), eventType: "status"));

Server-Sent Events (SSE) is a plain HTTP response that never quite ends. The server sends Content-Type: text/event-stream and then writes frames separated by a blank line:

event: status
data: {"orderId":2042,"status":"Shipped"}
id: 3
retry: 3000

Browsers consume it with EventSource, which reconnects automatically after a drop and sends the last id it saw in a Last-Event-ID header. .NET clients parse it with SseParser from System.Net.ServerSentEvents.

The .NET 10 result type

TypedResults.ServerSentEvents(source) takes an IAsyncEnumerable and writes each item as it is yielded:

  • IAsyncEnumerable<T>: each item is serialized as JSON into data:; an optional eventType argument names every event.
  • IAsyncEnumerable<string>: strings are written as-is.
  • IAsyncEnumerable<SseItem<T>>: full control per event: EventType, EventId and ReconnectionInterval (the retry: field).

The response is sent with Cache-Control: no-store, no-cache, and frames are flushed as they are produced.

Cancellation and shutdown

Write the source as an async iterator with a [EnumeratorCancellation] CancellationToken and pass it the handler's CancellationToken. When the client disconnects, the token fires, the pending await throws, and the iterator's finally blocks run, so subscriptions are released. Graceful shutdown is different: Kestrel stops accepting new connections and waits for running requests, and an SSE request never finishes on its own. Link IHostApplicationLifetime.ApplicationStopping into the iterator's token so streams end promptly instead of holding the shutdown until HostOptions.ShutdownTimeout expires and connections are cut.

Resuming and finishing

Give every event a monotonically increasing id and, on a new request, replay what came after Last-Event-ID. That needs the events in a store, not only in memory on one instance. When there is nothing more to send (the order was delivered), answer the reconnect with 204 No Content: per the HTML specification, EventSource stops reconnecting after a 204, while a stream that simply ends is reconnected.

SSE, WebSockets or SignalR

SSEWebSocketsSignalR
DirectionServer to clientBothBoth, with RPC
ProtocolOrdinary HTTPUpgrade to a socketWebSockets with fallbacks
Reconnect and resumeBuilt into EventSourceYou write itReconnect built in, resume is yours
FitsStatus feeds, progress, notifications, streaming textCustom binary protocolsChat, collaboration, groups

Production traps

  • Buffering proxies hold events back. nginx buffers by default; send X-Accel-Buffering: no or turn off proxy_buffering for the route.
  • Load balancers close idle connections (60 seconds on many). Send a heartbeat event every 15 to 30 seconds.
  • Over HTTP/1.1, browsers allow 6 connections per origin, so a seventh tab with a stream stalls. Serve over HTTP/2.
  • EventSource cannot set headers, so a bearer token cannot be sent. Use cookie authentication for browser streams.
  • Authorize per resource before returning the stream (the caller owns orderId), or any signed-in user reads any order's events. Authorization runs once, when the connection opens, so cap the stream's lifetime or re-check periodically so a revoked session stops receiving events.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Push order status changes (Paid, Packed, Shipped, Delivered) to the order tracking page without the browser polling every few seconds.
  • Resume a dropped stream from Last-Event-ID so a customer on a flaky mobile connection never misses the Shipped event.
  • Stream progress of a long invoice export to an admin dashboard and end the stream with 204 when the export has finished.

More examples

A heartbeat so idle connections survive

Load balancers and proxies close connections that carry no bytes for a while, often after 60 seconds, and the client then reconnects in a loop. A small <code>heartbeat</code> event every 20 seconds keeps the connection alive; browser code listens only for <code>price</code> events, so heartbeats are ignored. The bounded channel with <code>DropOldest</code> protects the server from a slow client: old prices are discarded instead of memory growing without limit.

Example · csharp
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using System.Threading.Channels;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<PriceTicker>();
var app = builder.Build();

app.MapGet("/products/{sku}/price-stream", (string sku, PriceTicker ticker, CancellationToken ct) =>
    TypedResults.ServerSentEvents(WithHeartbeat(ticker.Subscribe(sku, ct), TimeSpan.FromSeconds(20), ct)));

app.Run();

static async IAsyncEnumerable<SseItem<PriceChange?>> WithHeartbeat(
    ChannelReader<PriceChange> source, TimeSpan every, [EnumeratorCancellation] CancellationToken ct)
{
    while (!ct.IsCancellationRequested)
    {
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
        timeout.CancelAfter(every);
        PriceChange? next = null;
        try
        {
            if (!await source.WaitToReadAsync(timeout.Token)) yield break;
            source.TryRead(out next);
        }
        catch (OperationCanceledException) when (!ct.IsCancellationRequested)
        {
            // Nothing happened for 20 seconds: fall through and send a heartbeat.
        }

        yield return next is null
            ? new SseItem<PriceChange?>(null, "heartbeat")
            : new SseItem<PriceChange?>(next, "price");
    }
}

record PriceChange(string Sku, decimal Price, DateTimeOffset At);

sealed class PriceTicker
{
    public ChannelReader<PriceChange> Subscribe(string sku, CancellationToken ct)
    {
        // A real ticker registers this channel with the price source and unregisters it when ct fires.
        var channel = Channel.CreateBounded<PriceChange>(new BoundedChannelOptions(100) { FullMode = BoundedChannelFullMode.DropOldest });
        return channel.Reader;
    }
}

Consuming a stream from another .NET service

<code>SseParser.Create</code> with a deserializer callback turns each <code>data:</code> payload into a typed object, and <code>EventId</code> gives the resume point. A non-browser client has to implement what <code>EventSource</code> does for free: reconnect with a delay, send <code>Last-Event-ID</code>, and stop on 204. It must also check the status code, so an error page is not parsed as an empty stream, and catch <code>IOException</code> as well as <code>HttpRequestException</code>, because a dropped connection fails the read, and an unhandled exception in <code>ExecuteAsync</code> stops the host. The delay with backoff and jitter applies on every path, including a stream the server ended normally, so a broken upstream is never hammered in a hot loop. <code>HttpCompletionOption.ResponseHeadersRead</code> is essential: without it <code>SendAsync</code> tries to buffer the whole body, which never ends, until the 100-second <code>HttpClient.Timeout</code> fails the call.

Example · csharp
using System.Net.ServerSentEvents;
using System.Text.Json;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("orders", c => c.BaseAddress = new Uri("https://orders.internal.example"));
builder.Services.AddHostedService<ShipmentListener>();
var app = builder.Build();
app.Run();

sealed class ShipmentListener(IHttpClientFactory factory, ILogger<ShipmentListener> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var client = factory.CreateClient("orders");
        string? lastEventId = null;
        var failures = 0;

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                using var request = new HttpRequestMessage(HttpMethod.Get, "/orders/2042/events");
                if (lastEventId is not null) request.Headers.Add("Last-Event-ID", lastEventId);

                using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, stoppingToken);
                if (response.StatusCode == System.Net.HttpStatusCode.NoContent) return; // nothing more will come
                // A 401, 404 or 500 is not an empty stream: throw so it takes the backoff path.
                response.EnsureSuccessStatusCode();

                await using var stream = await response.Content.ReadAsStreamAsync(stoppingToken);
                var parser = SseParser.Create(stream, (type, data) => JsonSerializer.Deserialize<ShipmentEvent>(data, JsonSerializerOptions.Web));
                await foreach (var item in parser.EnumerateAsync(stoppingToken))
                {
                    lastEventId = item.EventId ?? lastEventId;
                    failures = 0;
                    logger.LogInformation("Order {OrderId} is now {Status}", item.Data?.OrderId, item.Data?.Status);
                }
                logger.LogInformation("Stream ended; reconnecting from event {LastEventId}", lastEventId);
            }
            // A mid-stream drop surfaces from the read as HttpIOException (an IOException), not
            // HttpRequestException; letting it escape would stop the whole host.
            catch (Exception ex) when (ex is HttpRequestException or IOException && !stoppingToken.IsCancellationRequested)
            {
                failures++;
                logger.LogWarning(ex, "Stream dropped; reconnecting from event {LastEventId}", lastEventId);
            }

            // Every path that loops waits first: exponential backoff capped at 60 s, plus jitter
            // so a fleet of listeners does not reconnect in lockstep after an upstream restart.
            var backoff = TimeSpan.FromSeconds(Math.Min(60, 3 * Math.Pow(2, Math.Min(failures, 5))));
            await Task.Delay(backoff + TimeSpan.FromMilliseconds(Random.Shared.Next(0, 1000)), stoppingToken);
        }
    }
}

record ShipmentEvent(int OrderId, string Status, DateTimeOffset At);

Browser client with EventSource

This is the exchange a browser makes after a dropped connection: <code>EventSource</code> adds <code>Last-Event-ID: 3</code> by itself and authenticates with the session cookie, because it cannot send an <code>Authorization</code> header. In JavaScript the page listens with <code>source.addEventListener("status", e =&gt; render(JSON.parse(e.data)))</code>; events with a named type are not delivered to <code>onmessage</code>, which only receives unnamed events.

Example · http
GET /orders/2042/events HTTP/1.1
Host: shop.example.com
Accept: text/event-stream
Cookie: .AspNetCore.Cookies=<session-cookie>
Last-Event-ID: 3

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-store, no-cache
X-Accel-Buffering: no

event: status
data: {"orderId":2042,"status":"OutForDelivery","at":"2026-10-22T09:14:03+00:00"}
id: 4
retry: 3000

event: status
data: {"orderId":2042,"status":"Delivered","at":"2026-10-22T11:40:51+00:00"}
id: 5
retry: 3000

Discussion

  • Be the first to comment on this lesson.