Server-Sent Events in .NET 10, explained by doing.
One long-lived HTTP response. The server keeps writing small text events; the browser keeps listening. .NET 10 turns that into a single line: return an IAsyncEnumerable and call TypedResults.ServerSentEvents.
What SSE is, in one picture
Server-Sent Events is a W3C/WHATWG web standard (it ships with HTML5) for one-way, server → client streaming over ordinary HTTP. The client asks once; the server never "finishes" the response and instead keeps appending events.
Like a radio
You tune in once (the HTTP GET). The station broadcasts whenever it has something. You can't talk back on the same channel — if you need to, you phone in separately (a normal POST).
Self-healing
If the signal drops, the browser's EventSource reconnects automatically and tells the server the last event it saw via the Last-Event-ID header, so the server can replay what was missed.
Just text
The body is UTF-8 lines like data: hello, events separated by a blank line. It flows through existing proxies, auth, CORS, logging and HTTP/2 multiplexing.
The lifecycle of one SSE connection
new EventSource('/feed') sends GET with Accept: text/event-stream.
Headers say text/event-stream, no cache. Response is not closed.
Each yield return becomes an event, flushed immediately.
Network blips → browser waits retry ms, reconnects with Last-Event-ID.
Client calls close(), or server answers non-200 / 204 to stop reconnects.
data: lines and called FlushAsync yourself. ASP.NET Core 10 adds a built-in result type, ServerSentEventsResult<T>, created with TypedResults.ServerSentEvents(...) (or Results.ServerSentEvents). It builds on the System.Net.ServerSentEvents library — SseItem<T>, SseFormatter and SseParser — which .NET also uses for consuming SSE from C#.
Anatomy of an event
An event is a block of field: value lines ending with a blank line. Only four field names mean anything; every other field is ignored.
data:The payload. Several consecutive data: lines are joined with a newline. In .NET this is SseItem<T>.Data.
event:The event name. Missing → "message" (fires onmessage). Named → only addEventListener(name). .NET: EventType.
id:The cursor. The browser remembers it as lastEventId and sends it back as Last-Event-ID on reconnect. .NET: EventId.
retry:Reconnect delay in integer milliseconds; non-integers are ignored. .NET: ReconnectionInterval (a TimeSpan).
: commentA line starting with a colon is ignored by clients — commonly used as a keep-alive heartbeat.
Interactive frame builder
Edit the fields and watch the exact bytes on the wire, what the browser dispatches, and the equivalent C#.
Break the connection. Watch it heal.
A fake ASP.NET Core endpoint streams price, news and unnamed events to a fake EventSource. Drop the network, change retry, turn replay off, or remove listeners — every rule you see here is the real browser behaviour.
/stream
EventSource
Client (browser)
Network & server
Resilience
Last-Event-ID and the yellow dashed replayed packets.Last-Event-ID — only automatic reconnects do. Persist the cursor yourself if you need it.Three overloads, one result type
TypedResults.ServerSentEvents has three overloads. Which one the compiler picks depends entirely on what you yield. All of them produce a ServerSentEventsResult<T>.
ServerSentEvents(IAsyncEnumerable<string> values, string? eventType = null)
Strings are written as-is — no JSON quotes or escaping. Best for plain text, log lines, or pre-serialized JSON.
ServerSentEvents<T>(IAsyncEnumerable<SseItem<T>> values)
You set EventType, EventId and ReconnectionInterval per event. Required for Last-Event-ID replay.
ServerSentEvents<T>(IAsyncEnumerable<T> values, string? eventType = null)
Yield plain objects; the framework wraps each in an SseItem<T> and JSON-serializes Data.
Overload resolver — find the trap
Pick what your iterator yields and whether you also pass eventType:. The ASP.NET team calls out the last combination as the classic mistake.
SseItem<T> — the event as a value
| Member | Wire field | Notes |
|---|---|---|
Data | data: | Your payload of type T. Strings and byte[] are written raw; anything else is JSON via the app's configured JsonOptions. |
EventType | event: | Set via constructor. Defaults to "message". |
EventId | id: | init property. Assign at publish time; same ID for every subscriber. |
ReconnectionInterval | retry: | TimeSpan? — written as integer milliseconds. |
IResult, so an action can simply return TypedResults.ServerSentEvents(...). Minimal APIs are where the docs and samples focus, and where you also get automatic OpenAPI response metadata.What ServerSentEventsResult does for you
This is taken from the framework source for ServerSentEventsResult<T>.ExecuteAsync. Press play to watch a request pass through it.
Tells EventSource (and proxies) this is a stream.
Stops caches from storing or serving a stale stream.
Response-compression middleware would otherwise buffer and batch events.
Each event leaves the server as soon as it is written.
Your ConfigureHttpJsonOptions settings (casing, converters, source-gen contexts) apply to Data.
Strings → raw · byte[] → raw · null → empty · others → UTF-8 JSON. Polymorphic runtime types are respected.
When the client disconnects, enumeration is cancelled and your iterator's finally runs.
Endpoint metadata (free)
Because the result implements IEndpointMetadataProvider, the endpoint automatically declares 200 producing SseItem<T> as text/event-stream for OpenAPI.
It also adds metadata that disables cookie-auth login redirects, so an unauthenticated SSE call gets a clean 401/403 instead of an HTML login page — which EventSource could never parse.
Before vs. after .NET 10
Step through real implementations
Each sample is highlighted line by line. Use Next / Prev or Auto-play. Every snippet targets .NET 10.
EventSource, in full
| API | What it does |
|---|---|
new EventSource(url, {withCredentials}) | Opens a GET. withCredentials: true sends cookies cross-origin (needs credentialed CORS). |
readyState | 0 CONNECTING · 1 OPEN · 2 CLOSED |
onopen | Fires on every successful (re)connection. |
onmessage | Only events without an event: field (type "message"). |
addEventListener('name') | Named events. No listener → event silently ignored. |
onerror | Network drop (state goes to CONNECTING and retries) or fatal failure (state CLOSED). |
e.lastEventId | The id associated with this event. |
close() | Stops for good. No more reconnects. |
When does the browser give up?
| Server / network does… | Browser reaction |
|---|---|
| Network error / server closes stream | Reconnect after retry |
| 200 with wrong Content-Type | Fail → CLOSED |
| 204 No Content | Stop → CLOSED (polite "go away") |
| 401 / 403 / 404 / 5xx | Fail → CLOSED |
| Redirect (3xx) | Followed, then rules above |
Authorization: Bearer) · text only · no built-in backoff tuning beyond retry. When you need a token, a POST body (AI chat) or custom headers, use fetch() + a stream reader — walkthrough "fetch + Bearer" shows a complete vanilla JS parser.Authentication options at a glance
Same-origin cookie
Cookies flow automatically with native EventSource. Add .RequireAuthorization() to the endpoint. Done.
withCredentials + CORS
Use { withCredentials: true }, an explicit allowed origin and AllowCredentials(). Wildcard origins won't work with credentials.
fetch-based client
EventSource can't set Authorization. Use fetch streaming; avoid putting tokens in query strings because URLs end up in logs.
SSE vs Polling vs Long polling vs WebSockets vs SignalR
The same random server updates (★) hit five transports. Arrows up = client → server request/message, arrows down = server → client. Watch latency and request counts.
Overhead is a rough estimate (≈ 400 B per HTTP request/response pair, ≈ 10 B framing per SSE/WebSocket message) to show proportions, not a benchmark.
| Capability | Polling | Long polling | SSE (.NET 10) | WebSockets | SignalR |
|---|---|---|---|---|---|
| Direction | Client pulls | Client pulls (held) | Server → client | Both ways | Both ways (RPC) |
| Protocol | HTTP | HTTP | HTTP (1.1/2/3) | Upgrade to ws:// | WS → SSE → long-poll fallback |
| Auto reconnect | n/a | You write it | Built in | You write it | Opt-in (withAutomaticReconnect) |
| Resume missed messages | Via cursor param | Via cursor param | Last-Event-ID | Custom | Custom |
| Binary payloads | Yes | Yes | Text only (base64) | Yes | MessagePack |
| Client library | None | None | None (EventSource) | None (WebSocket) | @microsoft/signalr |
| Groups / users / fan-out | — | — | You build it | You build it | Built in |
| Scale-out | Stateless | Stateful | Pub/sub + replay store | Pub/sub | Backplane / Azure SignalR, often sticky sessions |
| Works through proxies | Yes | Yes | Yes (disable buffering) | Needs Upgrade support | Falls back |
| Best for | Rare changes | Legacy | Feeds, progress, AI tokens, notifications | Games, chat, collaboration | Rich two-way .NET apps |
Which should I use?
Twelve places SSE shines
Click a scenario to see its event design and a live sample of what it puts on the wire.
Everything that bites in production
The API is one line. Running it at scale is where design matters. Open each item.
The HTTP/1.1 six-connection trap
Over HTTP/1.1, browsers allow about 6 connections per domain, shared across all tabs. Each SSE stream holds one forever. Open tabs and watch.
Kestrel negotiates HTTP/2 over HTTPS by default; with HTTP/2 the stream limit is negotiated (commonly 100), so the problem disappears. Make sure your reverse proxy speaks HTTP/2 to browsers too.
InfraReverse proxies & buffering (nginx, YARP, IIS, CDNs)
The framework disables its buffering, but a proxy in front can still collect the response and send it in one lump — events then arrive in bursts or only when the stream ends. For nginx, set proxy_buffering off; for the route or send the response header X-Accel-Buffering: no. Raise proxy_read_timeout above your heartbeat interval. Check CDN/edge products for "streaming" or "response buffering" settings.
InfraIdle timeouts & heartbeats
Load balancers and proxies kill connections that look idle (often after a minute or a few). If your stream can be quiet, send a small heartbeat every 15–30 s. SseItem<T> has no comment field, so the simplest approach is a tiny named event such as event: ping that the client never listens to (it's silently ignored). The heartbeat also makes the server notice dead clients faster, because writes to a dead socket fail.
DesignFan-out: Channels are competing readers
A Channel<T> delivers each item to one reader. If two browser tabs read the same channel, they split the events between them. For broadcast, give every connection its own bounded channel and have a publisher copy each event into all matching subscriptions (walkthrough "Broadcast hub").
DesignSlow consumers & backpressure
A phone on a bad network may read slower than you publish. Use Channel.CreateBounded per connection and pick a policy when it fills: disconnect (client reconnects and replays — safest), drop oldest (fine for "latest value wins" data like prices), or coalesce (send only the newest state). Never use unbounded channels per connection — that's a memory leak waiting for a slow client.
DesignEvent IDs, replay & resync
Assign the ID when publishing (monotonic sequence, Redis stream ID, DB row version), keep a bounded history, and replay everything after Last-Event-ID. Register the live subscription and capture the replay boundary under the same lock the publisher uses — otherwise events published between "read history" and "subscribe" vanish. If the cursor is older than retained history, return an explicit error (e.g. 409 or 204) before streaming so the client refetches a snapshot. IDs must not contain line breaks.
InfraScaling out to many instances
An in-memory hub only reaches connections on its own process. With several instances, publish through a pub/sub layer (Redis Pub/Sub or Streams, Azure Service Bus, NATS, Postgres LISTEN/NOTIFY) that every instance subscribes to, and keep replay history somewhere shared. Because every reconnect carries its own cursor, any instance can resume any client — no sticky sessions required.
SecurityAuthorization & data isolation
SSE endpoints are ordinary endpoints: .RequireAuthorization("policy"), rate limiting and CORS all apply. Route events by the authenticated user before they reach a connection — don't filter a shared stream after reading it. Treat Last-Event-ID as an untrusted cursor: authorize replayed events against the same user. Remember the token was validated only at connect time; for long streams, end the stream when the session expires so the client reconnects with fresh credentials.
OpsGraceful shutdown & deployments
Long-lived requests keep the host waiting during shutdown. Link RequestAborted with IHostApplicationLifetime.ApplicationStopping so streams end promptly on deploy; clients simply reconnect to a new instance and replay.
OpsResource cost & limits
An idle SSE connection in Kestrel is cheap — an async iterator awaiting a channel uses no thread. The cost is per-connection memory (socket, buffers, your channel) and open file descriptors. Set a connection budget, consider MaxConcurrentConnections, and apply rate limiting to the subscribe endpoint so a buggy client loop can't open thousands of streams.
OpsTesting & debugging
Use curl -N (no buffering) to watch raw frames, a .http file in Visual Studio / VS Code, or the Network tab's EventStream view in Chromium dev tools. For integration tests, host with WebApplicationFactory and parse the response with SseParser.
DesignOpenAPI & contracts
TypedResults.ServerSentEvents adds 200 text/event-stream metadata with the item type. Richer "sequential media type" descriptions from OpenAPI 3.2 are tracked as an open feature request in the ASP.NET Core repo, so document your event names and payload shapes in endpoint descriptions for now.
Common mistakes → fixes
SseItem<T> and pass eventType: to ServerSentEvents.SseItem constructor, or yield plain T and pass eventType.event: order, client only sets onmessage.es.addEventListener('order', …). onmessage only sees unnamed events.ChannelReader into every request.finally).[EnumeratorCancellation] on the iterator's token.CancellationToken) so disconnects stop the loop.proxy_buffering off / X-Accel-Buffering: no; don't add compression in the proxy.EventId at publish time and honour Last-Event-ID.?access_token= for EventSource.Authorization header.Minimal recipe
Sources
- Microsoft Learn — Create responses in Minimal API apps (Server-Sent Events)
- source.dot.net — ServerSentEventsResult.cs
- Microsoft Learn — System.Net.ServerSentEvents namespace
- NuGet — System.Net.ServerSentEvents
- Safia Abdalla — Anatomy of an API: three ways to stream with ServerSentEvents()
- Milan Jovanović — Server-Sent Events in ASP.NET Core and .NET 10
- Anton DevTips — Real-Time Server-Sent Events in ASP.NET Core and .NET 10
- MDN — Using server-sent events
- WHATWG HTML Standard — Server-sent events
- dotnet/aspnetcore #64379 — OpenAPI 3.2 sequential media types for SSE