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.
app.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 intodata:; an optionaleventTypeargument names every event.IAsyncEnumerable<string>: strings are written as-is.IAsyncEnumerable<SseItem<T>>: full control per event:EventType,EventIdandReconnectionInterval(theretry: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
| SSE | WebSockets | SignalR | |
|---|---|---|---|
| Direction | Server to client | Both | Both, with RPC |
| Protocol | Ordinary HTTP | Upgrade to a socket | WebSockets with fallbacks |
| Reconnect and resume | Built into EventSource | You write it | Reconnect built in, resume is yours |
| Fits | Status feeds, progress, notifications, streaming text | Custom binary protocols | Chat, collaboration, groups |
Production traps
- Buffering proxies hold events back. nginx buffers by default; send
X-Accel-Buffering: noor turn offproxy_bufferingfor 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.
EventSourcecannot 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
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.
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.
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 => render(JSON.parse(e.data)))</code>; events with a named type are not delivered to <code>onmessage</code>, which only receives unnamed events.
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