.NET 10 SSE
ASP.NET Core 10 · Minimal APIs · System.Net.ServerSentEvents

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.

1HTTP request, many events
4wire fields: data · event · id · retry
0client libraries needed
01The mental model

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

STEP 1
Client opens

new EventSource('/feed') sends GET with Accept: text/event-stream.

STEP 2
Server answers 200

Headers say text/event-stream, no cache. Response is not closed.

STEP 3
Events stream

Each yield return becomes an event, flushed immediately.

STEP 4
Drop & retry

Network blips → browser waits retry ms, reconnects with Last-Event-ID.

STEP 5
Close

Client calls close(), or server answers non-200 / 204 to stop reconnects.

What changed in .NET 10? Before .NET 10 you hand-wrote headers, formatted 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#.
02The wire format

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).

: comment

A 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#.

On the wire (text/event-stream)↵ = newline
Browser dispatches
03Live simulator

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

ServerSentEventsResult
PRODUCING
next id: 1
no connection

EventSource

lastEventId: ""
CLOSED (2)
0Received
0Replayed
0Lost
0No listener
3000retry ms

Client (browser)

Network & server

Resilience

server retry: 3000 ms
replay buffer size: 8 events
Raw HTTP response body
Browser console
Try thisConnect, wait for a few events, then Drop network. Note the request header Last-Event-ID and the yellow dashed replayed packets.
Then thisTurn replay off and drop again — the gap becomes "Lost". Shrink the buffer and pause for a long time — the server can't replay and must ask for a resync.
And thisClick es.close() then new EventSource(). A brand-new EventSource does not send Last-Event-ID — only automatic reconnects do. Persist the cursor yourself if you need it.
04The .NET 10 API surface

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>.

Overload 1 · strings

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.

Overload 2 · full control

ServerSentEvents<T>(IAsyncEnumerable<SseItem<T>> values)

You set EventType, EventId and ReconnectionInterval per event. Required for Last-Event-ID replay.

Overload 3 · convenience

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.

You yield…
…and pass eventType:
Resulting wire output

SseItem<T> — the event as a value

MemberWire fieldNotes
Datadata:Your payload of type T. Strings and byte[] are written raw; anything else is JSON via the app's configured JsonOptions.
EventTypeevent:Set via constructor. Defaults to "message".
EventIdid:init property. Assign at publish time; same ID for every subscriber.
ReconnectionIntervalretry:TimeSpan? — written as integer milliseconds.
Controllers work tooMVC controllers can return 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.
05Under the hood

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.

idle
1
Content-Type: text/event-stream

Tells EventSource (and proxies) this is a stream.

2
Cache-Control: no-cache,no-store · Pragma: no-cache

Stops caches from storing or serving a stale stream.

3
Content-Encoding: identity

Response-compression middleware would otherwise buffer and batch events.

4
IHttpResponseBodyFeature.DisableBuffering()

Each event leaves the server as soon as it is written.

5
Resolve JsonOptions from DI

Your ConfigureHttpJsonOptions settings (casing, converters, source-gen contexts) apply to Data.

6
SseFormatter.WriteAsync(...)

Strings → raw · byte[] → raw · null → empty · others → UTF-8 JSON. Polymorphic runtime types are respected.

7
Stops on HttpContext.RequestAborted

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

06Code walkthroughs

Step through real implementations

Each sample is highlighted line by line. Use Next / Prev or Auto-play. Every snippet targets .NET 10.

Step 1 / 1

07The browser side

EventSource, in full

APIWhat it does
new EventSource(url, {withCredentials})Opens a GET. withCredentials: true sends cookies cross-origin (needs credentialed CORS).
readyState0 CONNECTING · 1 OPEN · 2 CLOSED
onopenFires on every successful (re)connection.
onmessageOnly events without an event: field (type "message").
addEventListener('name')Named events. No listener → event silently ignored.
onerrorNetwork drop (state goes to CONNECTING and retries) or fatal failure (state CLOSED).
e.lastEventIdThe 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 streamReconnect after retry
200 with wrong Content-TypeFail → CLOSED
204 No ContentStop → CLOSED (polite "go away")
401 / 403 / 404 / 5xxFail → CLOSED
Redirect (3xx)Followed, then rules above
EventSource limitationsGET only · no custom headers (so no 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

Recommended

Same-origin cookie

Cookies flow automatically with native EventSource. Add .RequireAuthorization() to the endpoint. Done.

Cross-origin

withCredentials + CORS

Use { withCredentials: true }, an explicit allowed origin and AllowCredentials(). Wildcard origins won't work with credentials.

Bearer token

fetch-based client

EventSource can't set Authorization. Use fetch streaming; avoid putting tokens in query strings because URLs end up in logs.

08Comparison playground

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.

updates 7
poll every 3 s

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.

CapabilityPollingLong pollingSSE (.NET 10)WebSocketsSignalR
DirectionClient pullsClient pulls (held)Server → clientBoth waysBoth ways (RPC)
ProtocolHTTPHTTPHTTP (1.1/2/3)Upgrade to ws://WS → SSE → long-poll fallback
Auto reconnectn/aYou write itBuilt inYou write itOpt-in (withAutomaticReconnect)
Resume missed messagesVia cursor paramVia cursor paramLast-Event-IDCustomCustom
Binary payloadsYesYesText only (base64)YesMessagePack
Client libraryNoneNoneNone (EventSource)None (WebSocket)@microsoft/signalr
Groups / users / fan-out——You build itYou build itBuilt in
Scale-outStatelessStatefulPub/sub + replay storePub/subBackplane / Azure SignalR, often sticky sessions
Works through proxiesYesYesYes (disable buffering)Needs Upgrade supportFalls back
Best forRare changesLegacyFeeds, progress, AI tokens, notificationsGames, chat, collaborationRich two-way .NET apps

Which should I use?

09Real-world scenarios

Twelve places SSE shines

Click a scenario to see its event design and a live sample of what it puts on the wire.

10Production checklist

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.

11Cheat sheet

Common mistakes → fixes

MistakeYield SseItem<T> and pass eventType: to ServerSentEvents.
FixSet the event type in the SseItem constructor, or yield plain T and pass eventType.
MistakeServer sends event: order, client only sets onmessage.
Fixes.addEventListener('order', …). onmessage only sees unnamed events.
MistakeInjecting one shared ChannelReader into every request.
FixOne bounded channel per connection, registered/unregistered in the iterator (finally).
MistakeForgetting [EnumeratorCancellation] on the iterator's token.
FixAdd it (and pass the endpoint's CancellationToken) so disconnects stop the loop.
MistakeEvents arrive in bursts behind nginx.
Fixproxy_buffering off / X-Accel-Buffering: no; don't add compression in the proxy.
MistakeNo event IDs, so reconnects silently lose data.
FixSet EventId at publish time and honour Last-Event-ID.
MistakeBearer token in ?access_token= for EventSource.
FixPrefer cookies, or a fetch-based client with an Authorization header.
MistakeUsing SSE for a chat where clients type constantly.
FixFine for occasional POSTs; for heavy two-way traffic choose WebSockets/SignalR.

Minimal recipe

Sources