Server-Sent Events (.NET 10 Native)
Key Points
- Server-Sent Events (SSE) is HTTP/1.1+ unidirectional server→client push using a long-lived
text/event-streamresponse. Lighter than WebSocket; firewalls and proxies generally pass it through. TypedResults.ServerSentEvents(IAsyncEnumerable<SseItem<T>>)(.NET 10+) is the native ASP.NET Core helper — minimal-API friendly, OpenTelemetry-aware, JSON serialization built in.- Use SSE for: live dashboards, notifications, AI chat streaming, log streaming, market tickers — anywhere clients only need server pushes.
- Use WebSocket for bidirectional, SignalR for browser-broker abstraction with reconnects + groups + scaleout.
- HTTP/2 multiplexes SSE streams over a single connection — no 6-stream limit per host. Server load scales with active streams; bound concurrency.
Concepts (deep dive)
Wire format
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
data: hello
data: {"order": 42}
event: status
data: ready
id: 1234
data: with sequence id
retry: 5000
- Each event ends with a blank line (
\n\n). data:is the payload (multi-line allowed by repeatingdata:).event:is the event type (defaultmessage).id:enables resume viaLast-Event-IDheader on reconnect.retry:tells client retry interval on disconnect.
Native helper (.NET 10)
app.MapGet("/orders/stream", (CancellationToken ct) =>
TypedResults.ServerSentEvents(StreamOrders(ct)));
static async IAsyncEnumerable<SseItem<OrderUpdate>> StreamOrders([EnumeratorCancellation] CancellationToken ct)
{
var channel = Channel.CreateUnbounded<OrderUpdate>();
/* hook channel up to event source */
while (await channel.Reader.WaitToReadAsync(ct))
while (channel.Reader.TryRead(out var update))
yield return new SseItem<OrderUpdate>(update);
}
public record OrderUpdate(Guid Id, string Status);
SseItem<T> properties: - Data — payload, JSON-serialized. - EventType — optional event name. - EventId — optional id for Last-Event-ID resume. - ReconnectInterval — optional retry:.
Client side (browser)
const es = new EventSource('/orders/stream');
es.onmessage = (e) => console.log('default event', JSON.parse(e.data));
es.addEventListener('status', (e) => console.log('status', e.data));
es.onerror = (e) => console.log('reconnecting...');
EventSource auto-reconnects on disconnect, sending Last-Event-ID if you set id:.
Native client side (.NET)
using var http = new HttpClient { BaseAddress = new Uri("http://api/") };
var stream = http.GetServerSentEventsAsync<OrderUpdate>("/orders/stream", ct);
await foreach (var item in stream)
Console.WriteLine($"{item.EventType}: {item.Data}");
HttpClient.GetServerSentEventsAsync<T> parses the SSE format and yields typed items. Server-side TypedResults.ServerSentEvents() ships in Microsoft.AspNetCore.App.Ref v10.0.0 (.NET 10 GA). The client-side parser sits in the out-of-band System.Net.ServerSentEvents package — currently a preview package targeting .NET 11; track the latest version on NuGet.
Heartbeats
Browsers / proxies time out idle connections (~60-120 s). Send a comment line periodically:
Or use : keepalive\n\n raw — comments (lines starting with :) are ignored by clients but keep the connection warm.
HTTP/2 vs HTTP/1.1
- HTTP/1.1: one TCP connection per SSE stream. Browsers limit ~6 connections per origin.
- HTTP/2: multiplex many streams over one connection. No per-origin limit issue.
- HTTP/3 (QUIC): same multiplexing benefits, plus 0-RTT and migration.
For multi-stream apps, HTTP/2+ is essential.
vs SignalR
| SSE | SignalR | |
|---|---|---|
| Direction | Server → client only | Bidirectional |
| Browser support | All modern | All modern (SSE/WebSocket fallback) |
| Reconnect | Built-in EventSource | Built-in, with state replay |
| Groups / fan-out | Manual | Built-in IHubContext |
| Backplane (Redis) | Manual | Built-in |
| .NET 10 native | Yes | Yes |
If you only push, SSE is simpler. If you need bidirectional + groups + scaleout, SignalR.
vs WebSocket
WebSocket is bidirectional and binary-capable, but requires the upgrade handshake and is opaque to many proxies. SSE is a normal HTTP response — proxies, CDNs, gateways pass it through unmodified (with buffering disabled).
Backpressure and disconnects
static async IAsyncEnumerable<SseItem<T>> Stream([EnumeratorCancellation] CancellationToken ct)
{
using var sub = _bus.Subscribe(...);
while (!ct.IsCancellationRequested)
{
var item = await sub.NextAsync(ct);
yield return new SseItem<T>(item);
}
}
ct fires when the client disconnects (TCP RST or graceful close). Always pass cancellation through.
Buffering and flushing
Reverse proxies (nginx defaults) buffer responses, breaking SSE. Disable buffering on the route:
App side, the helper flushes after each event automatically.
Auth
EventSource uses cookies for auth. For bearer tokens, the browser API can't set headers — you either: - Pass token in query string (?access_token=...) — not ideal but accepted. - Use a server-rendered cookie session. - Use fetch + ReadableStream instead of EventSource (modern browsers).
Code: correct vs wrong
❌ Wrong: blocking the stream
app.MapGet("/stream", (CancellationToken ct) => TypedResults.ServerSentEvents(BadStream()));
static async IAsyncEnumerable<SseItem<int>> BadStream()
{
while (true) { Thread.Sleep(1000); yield return new SseItem<int>(0); } // blocks worker
}
✅ Correct: async only, honor CT
static async IAsyncEnumerable<SseItem<int>> Stream([EnumeratorCancellation] CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(1000, ct);
yield return new SseItem<int>(Random.Shared.Next());
}
}
❌ Wrong: forgetting heartbeats
Long idle stream → proxy kills connection at 60 s → client churn.
✅ Correct: periodic comment ping
var lastSent = DateTime.UtcNow;
while (!ct.IsCancellationRequested)
{
var item = await TryGetItem(ct);
if (item is not null) { yield return item; lastSent = DateTime.UtcNow; }
else if (DateTime.UtcNow - lastSent > TimeSpan.FromSeconds(15))
{
yield return new SseItem<string>("") { EventType = "ping" };
lastSent = DateTime.UtcNow;
}
}
Design patterns for this topic
Pattern 1 — Channel-fed SSE
public class StreamingBroker
{
private readonly ConcurrentDictionary<Guid, Channel<Update>> _subscribers = new();
public IAsyncEnumerable<Update> Subscribe(CancellationToken ct)
{
var ch = Channel.CreateBounded<Update>(100);
var id = Guid.NewGuid();
_subscribers[id] = ch;
ct.Register(() => { _subscribers.TryRemove(id, out _); ch.Writer.Complete(); });
return ch.Reader.ReadAllAsync(ct);
}
public void Publish(Update u)
{ foreach (var ch in _subscribers.Values) ch.Writer.TryWrite(u); }
}
Pluggable feed; each SSE endpoint subscribes via its own channel.
Pattern 2 — Resumable streams
app.MapGet("/events", (HttpContext ctx, CancellationToken ct) =>
{
long? since = ctx.Request.Headers.TryGetValue("Last-Event-ID", out var v) && long.TryParse(v, out var n) ? n : null;
return TypedResults.ServerSentEvents(StreamFrom(since, ct));
});
static async IAsyncEnumerable<SseItem<Event>> StreamFrom(long? since, [EnumeratorCancellation] CancellationToken ct)
{
await foreach (var ev in _store.ReadFromAsync(since, ct))
yield return new SseItem<Event>(ev) { EventId = ev.Sequence.ToString() };
}
Clients reconnect → EventSource sends Last-Event-ID → server resumes from there.
Pattern 3 — AI chat streaming
app.MapPost("/chat", async (ChatRequest r, IChatClient ai, CancellationToken ct) =>
{
return TypedResults.ServerSentEvents(StreamChat(r, ai, ct));
});
static async IAsyncEnumerable<SseItem<ChatChunk>> StreamChat(ChatRequest r, IChatClient ai,
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var update in ai.GetStreamingResponseAsync(r.Messages, cancellationToken: ct))
yield return new SseItem<ChatChunk>(new ChatChunk(update.Text!));
}
LLM token-by-token to browser via SSE — the dominant pattern in 2026 chat UIs.
Pattern 4 — Heartbeat from a dedicated timer
public static IAsyncEnumerable<SseItem<T>> WithHeartbeat<T>(IAsyncEnumerable<SseItem<T>> source, TimeSpan period, CancellationToken ct)
=> /* merge source with periodic heartbeat ping */;
Reusable across endpoints.
Pros & cons / trade-offs
| Aspect | SSE | WebSocket | SignalR |
|---|---|---|---|
| Direction | Server→client | Both | Both |
| Setup | Trivial | Upgrade handshake | Heaviest |
| Reconnect | Native | Manual | Native |
| Backplane | Manual | Manual | Built-in |
| Proxies | Friendly | Sometimes blocked | Friendly |
When to use / when to avoid
- Use SSE for one-way streams, AI chat completions, tickers, dashboards.
- Use WebSocket for game/voice protocols, binary data, bidirectional.
- Use SignalR for chat with rooms/groups + scaleout.
- Avoid SSE when bidirectional needed, or when the upstream proxy can't pass long-lived connections.
Interview Q&A
Q1. What's text/event-stream mime type? A. The official media type for Server-Sent Events: a long-lived HTTP response containing event blocks (data:, event:, id:, retry:) separated by blank lines.
Q2. How does EventSource reconnect? A. On connection drop, it waits the retry: interval (default 3 s) and reconnects, sending the last received id: as Last-Event-ID header so the server can resume.
Q3. Why use SSE over WebSocket? A. Simpler protocol, plays well with HTTP infrastructure (proxies, CDNs, auth), built-in reconnect and event IDs. Use WebSocket only when you need bidirectional or binary.
Q4. What does TypedResults.ServerSentEvents provide over manual write? A. Correct headers, JSON serialization of SseItem<T>, framing of data:/event:/id:/retry:, integration with IAsyncEnumerable, automatic flush after each event.
Q5. How do you authenticate SSE without bearer headers? A. Cookies are sent automatically by EventSource. For bearer tokens, either pass via query string (acceptable for short-lived), implement custom fetch+ReadableStream, or expose a session cookie endpoint that maps to the bearer.
Q6. How to send heartbeats? A. Yield a comment-line item ("" + EventType: "ping") every 15-30 s. Comments are ignored by clients but keep proxies/load balancers from idling out.
Q7. What's the HTTP/2 advantage for SSE? A. Multiplexes many streams over one TCP connection — bypasses the browser's 6-connections-per-host limit. Lower TCP overhead, faster reconnects.
Q8. How do you scale SSE across many servers? A. Use a backplane (Redis pub/sub, Service Bus topics, Kafka): each app instance subscribes; messages fan out. Bound per-instance subscriber count; consider sticky routing for resume scenarios.
Gotchas / common mistakes
- Sync work in the stream method blocks the worker thread.
- Reverse proxy buffering disabled fixes — silent failure otherwise.
- Heartbeats forgotten — proxies kill 60-second idle connections.
- Unbounded subscriber maps — memory leak.
- Disconnect not honored (
CancellationTokenignored) — phantom subscribers. - HTTP/1.1 alone — browser hits 6-connection limit if many tabs open.
- Bearer tokens —
EventSourcecan't set headers; need cookie or query-string. Last-Event-IDresume not implemented — clients lose events on reconnect.