Server-Sent Events in .NET 10


Server-Sent Events (SSE) are a lightweight, one way server push mechanism over plain HTTP. The client opens a long lived GET request and the server streams a series of lines formatted as per the W3C EventSource spec. Unlike WebSockets, SSE keeps things half duplex and text first, unlike long polling, it avoids noisy re-connects. That simplicity translates into fewer moving parts to debug and a nice mental model for append only event streams, live logs, progress updates, notifications, AI token streaming, telemetry tailing, and ‘who’s typing?’ indicators.
In earlier ASP.NET Core versions we usually made text/event-stream
responses: set the content type, write data:
lines, flush. It worked, but you had to remember edge cases like event IDs, retry intervals, buffering, and cancellation. .NET 10 lifts that weight, ASP.NET Core has a nice result type for SSE, and the base class libraries add a System.Net.ServerSentEvents
namespace with types for formatting and parsing events. In practice, this means you can return an IAsyncEnumerable<SseItem<T>>
from an endpoint via TypedResults.ServerSentEvents(…)
, and the runtime handles the wire format cleanly for you.
What else does this rely on in .NET 10?
Two key pieces matter, first, ASP.NET Core ships a built in SSE result so Minimal APIs and controllers can return SSE without bespoke plumbing, TypedResults.ServerSentEvents(...)
. The result takes an IAsyncEnumerable<SseItem<T>>
and optional metadata like eventType
. ASP.NET Core writes the correct text/event-stream
content type, formats items, and flushes correctly as you yield.
Second, the BCL introduces System.Net.ServerSentEvents
with SseItem<T>
(a value that represents one SSE message with optional event name and ID), plus helpers like SseFormatter
and SseParser
if you need lower level control or you’re building your own client/relay. You won’t always need the formatter/parser, but it’s great that they exist.
Together, these give you a clean, typed programming model for server push that slots neatly into modern ASP.NET Core without ceremony.
Choosing SSE?
Use SSE when the server needs to push a unidirectional stream of text or JSON in near real time and the browser is your primary client. It’s perfect for incremental results from long running job progress, monitoring dashboards, market tickers, multiplayer presence, or continuous export of logs. If you need bi directional messaging, binary payloads, or strict latency SLAs across non browser clients, you still should go for WebSockets or gRPC. For occasionally updated pages, long polling might be simpler.
The big advantages in practice are the trivial client API (EventSource
), zero protocol upgrades (just HTTP/1.1+), and easy observability because your traffic looks like normal GETs.
A Minimal API example you can run now
Create an empty ASP.NET Core project targeting .NET 10. Then replace Program.cs
with the following. This publishes a heartbeat every two seconds and demonstrates event IDs and graceful cancellation.
using System.Runtime.CompilerServices;
using System.Net.ServerSentEvents; // SseItem<T>
using Microsoft.AspNetCore.Http.HttpResults;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/time-stream", (HttpContext http, CancellationToken ct) =>
{
async IAsyncEnumerable<SseItem<string>> Stream([EnumeratorCancellation] CancellationToken token)
{
var id = 0L;
while (!token.IsCancellationRequested)
{
id++;
var payload = $"{{\"utc\":\"{DateTime.UtcNow:O}\"}}";
yield return new SseItem<string>(
data: payload,
eventType: "time",
id: id.ToString()
);
await Task.Delay(TimeSpan.FromSeconds(2), token);
}
}
ServerSentEvents<SseItem<string>> result = TypedResults.ServerSentEvents(
Stream(ct),
eventType: null,
retry: TimeSpan.FromSeconds(3)
);
return result;
})
.WithName("TimeStream");
app.MapGet("/", () => Results.Text("""
<!doctype html>
<html>
<body>
<h1>Time Stream</h1>
<pre id="log"></pre>
<script>
const log = document.getElementById('log');
const src = new EventSource('/time-stream');
src.addEventListener('time', (e) => {
log.textContent += 'event#' + e.lastEventId + ' ' + e.data + '\\n';
});
src.onerror = (e) => {
console.warn('SSE error, browser will retry automatically', e);
};
</script>
</body>
</html>
""", "text/html"));
app.Run();
Notice that you yield SseItem<string>
values from an IAsyncEnumerable
and return TypedResults.ServerSentEvents(…)
. ASP.NET Core takes care of the headers and streaming semantics. On the client, EventSource
is as simple as it gets. If the connection drops, the browser reconnects and passes a Last-Event-ID
header so you can resume if you want to implement that logic server-side. The new docs explicitly describe the SseItem<T>
shape and the Minimal API support.
A controller based example with background fan out
Minimal APIs are cool, but many teams are on controllers and want to broadcast the same stream to multiple subscribers. Here’s a tidy approach, a hosted service that produces events into a Channel<SseItem<T>>
, and a controller that exposes that channel as SSE. This gives you back pressure and a single producer feeding many consumers.
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using System.Net.ServerSentEvents;
public interface ILiveFeed
{
IAsyncEnumerable<SseItem<string>> Subscribe(CancellationToken ct);
void Publish(string json, string? eventType = null, string? id = null);
}
public sealed class LiveFeedService : BackgroundService, ILiveFeed
{
private readonly Channel<SseItem<string>> _channel =
Channel.CreateUnbounded<SseItem<string>>(new UnboundedChannelOptions
{
SingleWriter = false,
SingleReader = false,
AllowSynchronousContinuations = false
});
public void Publish(string json, string? eventType = null, string? id = null)
=> _channel.Writer.TryWrite(new SseItem<string>(json, eventType, id));
public async IAsyncEnumerable<SseItem<string>> Subscribe(
[EnumeratorCancellation] CancellationToken ct)
{
var reader = _channel.Reader;
yield return new SseItem<string>("\"connected\":true", "status", "0");
while (await reader.WaitToReadAsync(ct))
{
while (reader.TryRead(out var item))
{
yield return item;
}
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
long id = 0;
while (!stoppingToken.IsCancellationRequested)
{
id++;
Publish($"{{\"tick\":{id},\"utc\":\"{DateTime.UtcNow:O}\"}}", "tick", id.ToString());
await Task.Delay(1000, stoppingToken);
}
}
}
// Program.cs
using System.Net.ServerSentEvents;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddHostedService<LiveFeedService>();
builder.Services.AddSingleton<ILiveFeed>(sp =>
(ILiveFeed)sp.GetRequiredService<LiveFeedService>());
var app = builder.Build();
app.MapControllers();
app.Run();
// LiveController.cs
using Microsoft.AspNetCore.Mvc;
using System.Net.ServerSentEvents;
[ApiController]
[Route("api/live")]
public class LiveController : ControllerBase
{
private readonly ILiveFeed _feed;
public LiveController(ILiveFeed feed) => _feed = feed;
[HttpGet("stream")]
public ServerSentEvents<SseItem<string>> Stream(CancellationToken ct)
{
return TypedResults.ServerSentEvents(
_feed.Subscribe(ct),
retry: TimeSpan.FromSeconds(5)
);
}
}
Run this and hit GET /api/live/stream
with a browser or curl
to watch the feed. The docs confirm the controller support mirrors the Minimal API experience.
Shaping the wire format
SSE isn’t JSON. It’s a light text protocol with fields (data:
, event:
, id:
, retry:
), blank lines separating events. ASP.NET Core will serialise non string
payloads as JSON for you if you choose SseItem<MyType>
; or you can pre serialise to string
and pass exactly what you want over the wire. Where you need total control, reach for SseFormatter
/SseParser
in System.Net.ServerSentEvents
to compose or parse events manually. The API surface includes SseItem<T>
, SseFormatter
, and SseParser
types.
A simple example that streams typed records:
public record Heartbeat(int Bpm, DateTimeOffset At);
app.MapGet("/heart", (CancellationToken ct) =>
{
async IAsyncEnumerable<SseItem<Heartbeat>> Beats([EnumeratorCancellation] CancellationToken token)
{
var rand = new Random();
while (!token.IsCancellationRequested)
{
yield return new SseItem<Heartbeat>(
new Heartbeat(rand.Next(60, 100), DateTimeOffset.UtcNow),
eventType: "heartRate"
);
await Task.Delay(1500, token);
}
}
return TypedResults.ServerSentEvents(Beats(ct));
});
This is the same pattern shown in the .NET 10 “What’s new” doc, where the team demonstrates an IAsyncEnumerable<SseItem<T>>
and the new TypedResults.ServerSentEvents
factory.
Consuming SSE - browsers and .NET clients
In browsers you use EventSource
. It automatically reconnects, preserves Last-Event-ID
, and dispatches typed named events. Nothing new there.
<script>
const es = new EventSource('/heart');
es.addEventListener('heartRate', e => {
const data = JSON.parse(e.data);
console.log('BPM', data.bpm, 'at', data.at);
});
es.onopen = () => console.log('connected');
es.onerror = (err) => console.warn('lost connection; retrying soon', err);
</script>
On .NET clients, you have options. If your consumer is Blazor WebAssembly or a webview, prefer EventSource
via JS interop. If you’re in a .NET worker/desktop app and want to parse SSE, you can use the new parser APIs in System.Net.ServerSentEvents
to read a long-running HttpClient
stream and emit items as they arrive (the namespace and parser types are documented in the API browser).
A sketch of the idea:
using System.Net.Http;
using System.Net.ServerSentEvents;
var http = new HttpClient();
using var resp = await http.GetAsync("https://localhost:5001/heart", HttpCompletionOption.ResponseHeadersRead);
resp.EnsureSuccessStatusCode();
await using var stream = await resp.Content.ReadAsStreamAsync();
var parser = new SseParser<Heartbeat>(payloadBytes =>
{
// payloadBytes -> parse to Heartbeat (e.g., JSON via Utf8JsonReader)
// return parsed instance
});
// Now loop reading from stream, feeding the parser, and reacting to completed items.
// (Left as an exercise for brevity.)
If you don’t need .NET consumption, skip this and keep your client code all browser-side with EventSource
.
Cancellation, heartbeats, buffering and backpressure
Every long lived HTTP stream needs a few basics:
Cancellation. Always accept an endpoint CancellationToken
. The server should exit the iterator gracefully when the client disconnects. The samples above do this, and the built in SSE result respects cancellation automatically.
Heartbeats. Proxies or load balancers may close idle connections. Send occasional keep alive messages or comments to ensure bytes flow. With the new API you can yield a tiny item on a timer, or build a ping into your background service.
Retry hint. The retry:
field suggests how long the browser should wait before reconnecting. With TypedResults.ServerSentEvents(…, retry: …)
you can set this once and keep code tidy.
Backpressure. If your producer runs faster than consumers can read, channel based fan out lets you bound memory. Prefer Channel.CreateBounded
with a drop or oldest-item policy when streaming hot signals like telemetry.
Ordering, idempotency and resumption
The EventSource spec lets clients send Last-Event-ID
on reconnect. If your events are idempotent and carry a logical monotonic ID, you can resume from the last delivered item. At the endpoint, read Request.Headers["Last-Event-ID"]
, convert to your sequence type, and skip older events in your iterator before resuming normal emission.
app.MapGet("/audit", (HttpContext ctx, CancellationToken ct) =>
{
long.TryParse(ctx.Request.Headers["Last-Event-ID"], out var last);
async IAsyncEnumerable<SseItem<string>> Stream([EnumeratorCancellation] CancellationToken token)
{
foreach (var evt in LoadAuditFrom(last + 1))
{
yield return new SseItem<string>(evt.PayloadJson, "audit", evt.Id.ToString());
await Task.Yield();
if (token.IsCancellationRequested) yield break;
}
}
return TypedResults.ServerSentEvents(Stream(ct));
});
With this, clients that briefly drop and reconnect won’t miss events.
Observability, metrics and logs you actually care about
Emit counters for active SSE connections, bytes written per stream, and stream duration. In Kestrel, each connection is just an HTTP request, your normal request logging fires once when the stream is accepted and once when it ends. Add structured logs on accept/close with the Last-Event-ID
so you can correlate reconnect storms after deployments. If you’re on OpenTelemetry, tag spans with sse.stream=true
and annotates for each emitted item count every N seconds to avoid high-cardinality spans.
Production hosting, Kestrel, reverse proxies, and Azure
Most issues people hit with SSE aren’t code, they’re intermediary defaults. The good news, the same operational guidance you’ve used for standard SSE applies to the new .NET 10 APIs.
Disable response buffering. In NGINX, set proxy_buffering off;
and proxy_http_version 1.1;
. In IIS/ARR, ensure dynamic compression is off for text/event-stream
and set response buffering disable where applicable. Azure Front Door and API Management can front SSE fine, but you must allow long request timeouts and disable buffering on the path. If you see events bunch up and arrive in bursts, something in the chain is buffering.
Time outs. Increase idle timeouts and keep alive pings. In App Service/ACA, set generous WEBSITES_CONTAINER_START_TIME_LIMIT
and app level timeouts; configure Front Door’s Send Timeout
high enough for your use case. Many teams land on 60-120 minutes for dashboards.
Scaling. Each SSE client ties up one Kestrel request, but not a thread. Use async write patterns and keep allocations low. Horizontal scale is straightforward, just remember that per instance state won’t reach all clients. For fan out you’ll need a shared bus (Service Bus/Redis) or accept per instance audiences. For security, treat an SSE stream like any other GET endpoint, enforce auth, validate scopes, avoid streaming secrets back by accident, and consider a per user stream limit to avoid abuse.
Performance
SSE is mostly about not doing too much. Prefer IAsyncEnumerable
so you naturally yield on I/O and timers. Avoid allocating new JSON serialisers per item. Don’t flush after every line, ASP.NET Core manages flushing appropriately for you when returning the standard result. If you generate events faster than the network can carry them, coalesce updates (e.g., send latest every 200ms rather than 60 per second).
The official ‘What’s New in ASP.NET Core in .NET 10’ highlights the SSE feature explicitly, with an example mapping a GET
endpoint to TypedResults.ServerSentEvents
and using SseItem<T>
for the payload. The BCL API browser documents System.Net.ServerSentEvents
with SseItem<T>
, SseFormatter
, and SseParser
.
You can read more about the feature and others coming in .NET 10 here…
https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-10.0
Subscribe to my newsletter
Read articles from Patrick Kearns directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
