Server-Sent Events in Depth: TypedResults.ServerSentEvents, Resuming with Last-Event-ID and Proxy Pitfalls
To make up for missed SSE events, give every event an EventId, resend everything after the Last-Event-ID header and send a heartbeat as soon as the client connects. SseItem<T>, retry, CancellationToken, the Nginx gzip and timeout traps, HTTP/1.1's 6-connection limit, wrapping EventSource in an Observable in Angular, and reading with fetch when you need headers.
Version note: The code in this post was built and run with .NET 10, Angular 22.2 and Nginx 1.29.
Guess First
You have an SSE (Server-Sent Events) endpoint that returns an IAsyncEnumerable through TypedResults.ServerSentEvents. A client connects, but nothing has happened yet on the order it watches; the first event will arrive 40 seconds from now. When does the client receive the 200 OK response? Immediately, or in 40 seconds? And what happens if an Nginx with default settings sits in front of it?
In this post we chase details like this one, which make SSE work flawlessly locally and break silently in production. The answer is in the section about the first event.
SseItem<T>: Event Type, Id and Retry
In the ".NET 10 What's New" post of the ASP.NET Core Web API (Application Programming Interface) series, we gave TypedResults.ServerSentEvents an IAsyncEnumerable<T> and sent every item as an event. Resuming and heartbeats need more control, and that comes with SseItem<T>:
public sealed record OrderEvent(long Sequence, int OrderId, string Status, DateTimeOffset At);
public async IAsyncEnumerable<SseItem<OrderEvent?>> ReadAsync(
int orderId, long lastEventId, [EnumeratorCancellation] CancellationToken cancellationToken)
{
logger.LogInformation("Client connected for order {OrderId}, Last-Event-ID: {LastEventId}", orderId, lastEventId);
int sent = 0;
try
{
// the response headers do not go out until the first item does: send a heartbeat right away,
// and tell the browser "if it drops, retry after 3 s"
yield return new SseItem<OrderEvent?>(null, "heartbeat") { ReconnectionInterval = TimeSpan.FromSeconds(3) };
while (!cancellationToken.IsCancellationRequested)
{
Task changed;
OrderEvent[] pending;
lock (_gate)
{
pending = [.. _events.Where(e => e.OrderId == orderId && e.Sequence > lastEventId)];
changed = _changed.Task;
}
foreach (OrderEvent e in pending)
{
yield return new SseItem<OrderEvent?>(e, "order-status") { EventId = e.Sequence.ToString() };
lastEventId = e.Sequence;
sent++;
}
if (!await WaitOrTimeoutAsync(changed, HeartbeatInterval, cancellationToken))
{
// 15 s of silence: keep proxies from treating the connection as idle and cutting it
yield return new SseItem<OrderEvent?>(null, "heartbeat");
}
}
}
finally
{
logger.LogInformation("Client left order {OrderId} after {Sent} events", orderId, sent);
}
}
The OrderEventLog class keeps the events in a list with a sequence number; _changed is a TaskCompletionSource that signals "something new was added", as in the previous post. WaitOrTimeoutAsync is a small helper that calls changed.WaitAsync(timeout, cancellationToken) and returns false on a timeout; we moved that out because an iterator cannot yield return from inside a catch block (CS1631).
The four parts of SseItem<T> map to the four fields of the SSE format: Data → data:, EventType → event:, EventId (added in .NET 10) → id: and ReconnectionInterval → retry:. After adding three order events and connecting with curl:
$ curl -s -i -N http://localhost:17603/orders/42/events
HTTP/1.1 200 OK
Content-Type: text/event-stream
Date: Sun, 08 Nov 2026 02:19:51 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Content-Encoding: identity
Pragma: no-cache
Transfer-Encoding: chunked
event: heartbeat
data:
retry: 3000
event: order-status
data: {"sequence":1,"orderId":42,"status":"Received","at":"2026-11-08T02:19:51.95221+00:00"}
id: 1
event: order-status
data: {"sequence":2,"orderId":42,"status":"Packed","at":"2026-11-08T02:19:51.963727+00:00"}
id: 2
event: order-status
data: {"sequence":3,"orderId":42,"status":"Shipped","at":"2026-11-08T02:19:51.9701+00:00"}
id: 3
Look at the headers: we did not write Cache-Control: no-cache,no-store or Content-Encoding: identity; ServerSentEventsResult does. Looking at its source code, I saw it also turns off response buffering with DisableBuffering(). We will see why these three details matter in the proxy section.
The event type also shapes the browser side: messages without an event: line go to onmessage, while those with event: order-status are only received through addEventListener('order-status', ...). That is why we send heartbeats with a separate type; since nobody listens for heartbeat, no code runs in the browser.
Resuming with Last-Event-ID
app.MapGet("/orders/{id:int}/events", (int id, HttpRequest request, OrderEventLog log, CancellationToken cancellationToken) =>
{
// when reconnecting, the browser sends the last id it saw in this header
long lastEventId = long.TryParse(request.Headers["Last-Event-ID"], out long parsed) ? parsed : 0;
return TypedResults.ServerSentEvents(log.ReadAsync(id, lastEventId, cancellationToken));
});
This is SSE's biggest advantage over WebSocket: when the connection drops, EventSource reconnects on its own and sends the last id it received in the Last-Event-ID header. Your job is to send everything after that number again. Trying it by hand with curl:
$ curl -s -N http://localhost:17603/orders/42/events -H "Last-Event-ID: 2"
event: heartbeat
data:
retry: 3000
event: order-status
data: {"sequence":3,"orderId":42,"status":"Shipped","at":"2026-11-08T02:19:51.9701+00:00"}
id: 3
I ran the real test in the browser. While the Angular app was connected through Nginx, I restarted the Nginx container with docker restart -t 0, added "Shipped" to the order in the meantime, and "OutForDelivery" a few seconds later. The browser console:
[sse] open
[sse] id=1
[sse] id=2
[sse] error, readyState=0
[sse] open
[sse] id=3
[sse] id=4
The server log:
info: Bookstore.Realtime.OrderEventLog[0]
Client connected for order 42, Last-Event-ID: 0
info: Bookstore.Realtime.OrderEventLog[0]
Client left order 42 after 2 events
info: Bookstore.Realtime.OrderEventLog[0]
Client connected for order 42, Last-Event-ID: 2
Event 3, added during the drop, was not lost. The browser honoured retry: 3000, waited 3 seconds, came back with Last-Event-ID: 2 and received the missing event. You do not get this for free: you have to store events on the server with a sequence number. Here we used an in-memory list; for it to work across multiple servers or a restart, the log has to live somewhere shared, such as Redis Streams, a database table or an outbox. Since you cannot keep the log forever, set a retention period too; if the id the client asks for is no longer in the log, send a special event that tells it "do a full refresh".
Heartbeats, the First Byte and the Client Leaving
The answer to the opening question: in the first version, that initial heartbeat line was not in the code, and with no events curl received not a single byte for 3 seconds, 200 OK included. ServerSentEventsResult writes the headers together with the first item. When I put an Nginx with proxy_read_timeout 10s in front, the client saw:
HTTP/1.1 504 Gateway Time-out
Content-Type: text/html
Content-Length: 167
Connection: keep-alive
upstream timed out (110: Operation timed out) while reading response header from upstream
Nginx is saying "I was waiting for the headers and they never came". In the browser this means EventSource gets an error without onopen ever firing. The fix is simple: send an item as soon as the client connects. We do that with a heartbeat that also carries the retry value.
The second problem is an idle connection. Nginx's proxy_read_timeout defaults to 60 seconds; if not a single byte arrives from upstream in that time, it cuts the connection. Load balancers have similar idle timeouts. The heartbeat interval must be shorter than all of them. I deliberately set it the wrong way round (heartbeat every 15 s, proxy_read_timeout 10s):
HTTP/1.1 200 OK
event: heartbeat
data:
retry: 3000
real 10.01
Thanks to the first heartbeat the 200 arrived, but the connection was cut after 10 seconds. The browser would reconnect, but a stream that drops every 10 seconds pollutes the logs and adds pointless reconnection cost. Sending the heartbeat as an SSE comment line (: ping) is also a standard approach; because SseItem<T> has no comment support, we used a separate event type here.
The last piece is the client leaving. The CancellationToken passed to the endpoint is the request's RequestAborted token; it fires when the client closes the tab, WaitAsync is cancelled, and the iterator's finally block runs. The logs from running curl with --max-time 2 are above: "Client left order 42 after 3 events". That finally block is the right place to clean up anything you keep per connection (subscriptions, counters, presence records).
Two Real Scenarios: Report Progress and AI Token Streaming
public sealed record ReportProgress(int ReportId, int Percent, string? DownloadUrl = null);
public static class ReportGenerator
{
public static async IAsyncEnumerable<SseItem<ReportProgress>> RunAsync(
int reportId, [EnumeratorCancellation] CancellationToken cancellationToken)
{
for (int percent = 0; percent < 100; percent += 25)
{
yield return new SseItem<ReportProgress>(new ReportProgress(reportId, percent), "progress");
await Task.Delay(500, cancellationToken);
}
yield return new SseItem<ReportProgress>(
new ReportProgress(reportId, 100, $"/reports/{reportId}/download"), "completed");
}
}
app.MapGet("/reports/{id:int}/progress", (int id, CancellationToken cancellationToken) =>
TypedResults.ServerSentEvents(ReportGenerator.RunAsync(id, cancellationToken)));
event: progress
data: {"reportId":9,"percent":0,"downloadUrl":null}
event: progress
data: {"reportId":9,"percent":25,"downloadUrl":null}
event: progress
data: {"reportId":9,"percent":50,"downloadUrl":null}
event: progress
data: {"reportId":9,"percent":75,"downloadUrl":null}
event: completed
data: {"reportId":9,"percent":100,"downloadUrl":"/reports/9/download"}
When the work finishes, the iterator ends and the response closes. There is a trap here: the browser's EventSource treats a connection closed by the server as a drop, reconnects, and the report is generated from scratch. The client must call close() when it receives the completed event. In a real application it is sturdier to hand report generation to a background job and make the SSE endpoint a watcher that only reads the job's status; then a reconnect does not restart the work.
The second scenario is streaming AI (Artificial Intelligence) answers token by token. We do not call a real model here; there is a fake assistant that streams a fixed answer word by word, because the topic is the transport layer rather than the model:
public sealed class FakeAssistant
{
// streams a fixed answer word by word instead of a real model
public static async IAsyncEnumerable<string> StreamAsync(
string question, [EnumeratorCancellation] CancellationToken cancellationToken)
{
string answer = "Clean Code is a good next read after Refactoring.";
foreach (string token in answer.Split(' '))
{
await Task.Delay(150, cancellationToken);
yield return token + " ";
}
}
}
app.MapGet("/assistant/answer", (string question, HttpRequest request, ILogger<FakeAssistant> logger, CancellationToken cancellationToken) =>
{
logger.LogInformation("Authorization header present: {Present}", request.Headers.Authorization.Count > 0);
return TypedResults.ServerSentEvents(FakeAssistant.StreamAsync(question, cancellationToken), eventType: "token");
});
When you pass an IAsyncEnumerable<string>, the strings are written to the data: line without being converted to JSON (JavaScript Object Notation). Since the streaming API of a real LLM (Large Language Model) client usually returns an IAsyncEnumerable as well, the two ends can be wired together directly. When the user presses "stop" and the connection closes, the CancellationToken travels all the way down to the model call and, with most providers, stops generation and therefore token spending; that is a difference you see on the bill.
Proxy Pitfalls: Buffering, Compression and Timeouts
Because SSE is plain HTTP (Hypertext Transfer Protocol), it passes through every proxy; but any proxy may treat it as "a normal, long-running response" and try to optimise it. I tried this in an Nginx 1.29 container with three different locations:
server {
listen 80;
# default settings: proxy_buffering on
location /buffered/ {
proxy_pass http://host.docker.internal:17603/;
proxy_http_version 1.1;
}
# a location with compression on
location /gzip/ {
proxy_pass http://host.docker.internal:17603/;
proxy_http_version 1.1;
proxy_set_header Accept-Encoding "";
gzip on;
gzip_types text/event-stream;
}
# the right settings for SSE
location /live/ {
proxy_pass http://host.docker.internal:17603/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
}
}
I measured the arrival time of each line. First I called, through /gzip/, a "manual" endpoint that writes text/event-stream itself and flushes after every event, without TypedResults:
== /gzip/orders/42/events-manual
Content-Encoding: gzip
2.99s data: {"orderId":42,"tick":1}
2.99s data: {"orderId":42,"tick":2}
2.99s data: {"orderId":42,"tick":3}
Three events produced one second apart reached the client at the same moment, when the stream ended. Nginx's gzip module held the data back in order to compress it. The report endpoint, which uses TypedResults.ServerSentEvents, through the same location:
== /gzip/reports/9/progress
Content-Encoding: identity
0.00s data: {"reportId":9,"percent":0,"downloadUrl":null}
0.49s data: {"reportId":9,"percent":25,"downloadUrl":null}
0.99s data: {"reportId":9,"percent":50,"downloadUrl":null}
1.50s data: {"reportId":9,"percent":75,"downloadUrl":null}
2.00s data: {"reportId":9,"percent":100,"downloadUrl":"/reports/9/download"}
The difference is the Content-Encoding: identity header: because the response is marked as "already encoded", Nginx did not try to compress it. When I added text/event-stream to the application's own UseResponseCompression middleware, the picture was the same: the manual endpoint was gzipped (because this middleware also flushes the compressor on every FlushAsync, the events still arrived on time), and the TypedResults one was not compressed at all. In my environment, proxy_buffering on on its own caused no delay; when the client reads fast, Nginx passes incoming data on without holding it. Still, turning it off as in /live/ avoids surprises with slow clients and caches in between. If you want to turn it off for a single endpoint from the application, adding an X-Accel-Buffering: no header to the response tells Nginx the same thing.
The browser's 6-connection limit
Over HTTP/1.1, browsers open at most 6 concurrent connections to the same host, and every EventSource permanently occupies one of them. I opened 7 EventSource instances to the same server in the browser:
{"openedAfter3s":[1,5,4,3,2,6],"afterClosingFirst":[1,5,4,3,2,6,7]}
The seventh waited until I closed the first. If a user opens your app in 7 tabs, or you open several different streams on one page, the next requests (ordinary API calls included) hang. The fix is HTTP/2: because it carries many streams over a single TCP (Transmission Control Protocol) connection, the limit effectively goes away (Kestrel's default is 100 concurrent streams per connection). Browsers only use HTTP/2 over TLS (Transport Layer Security), so HTTPS (HTTP Secure) is a must in production anyway. The second fix is to open a single stream per page and carry different event types in it.
Angular: Wrapping EventSource in an Observable
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
export interface OrderEvent {
sequence: number;
orderId: number;
status: string;
at: string;
}
@Injectable({ providedIn: 'root' })
export class OrderEventsService {
private readonly baseUrl = 'http://localhost:17630/live';
watch(orderId: number): Observable<OrderEvent> {
return new Observable<OrderEvent>((subscriber) => {
const source = new EventSource(`${this.baseUrl}/orders/${orderId}/events`, { withCredentials: true });
source.addEventListener('order-status', (event: MessageEvent<string>) => {
console.log(`[sse] id=${event.lastEventId}`);
subscriber.next(JSON.parse(event.data) as OrderEvent);
});
source.onopen = () => console.log('[sse] open');
source.onerror = () => {
// CONNECTING (0): the browser will reconnect by itself after the retry interval
// CLOSED (2): the browser gave up (e.g. a non-200 response), now it is our problem
console.log(`[sse] error, readyState=${source.readyState}`);
if (source.readyState === EventSource.CLOSED) {
subscriber.error(new Error('SSE connection closed by the browser'));
}
};
// close the connection on unsubscribe
return () => source.close();
});
}
}
The Observable's teardown function calls source.close(); when the component is destroyed, the connection closes too. Checking readyState in onerror matters: CONNECTING means "the browser will handle it", and if you end the Observable with an error here, an RxJS retry clashes with the browser's own reconnection and two connections get opened. The browser only gives up (CLOSED) when the server returns a non-200 response or the wrong content type.
import { Component, inject, input } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { scan, switchMap } from 'rxjs';
import { OrderEvent, OrderEventsService } from './order-events.service';
@Component({
selector: 'app-order-timeline',
template: `
<ul>
@for (event of events(); track event.sequence) {
<li>#{{ event.sequence }} {{ event.status }}</li>
}
</ul>
`,
})
export class OrderTimelineComponent {
private readonly orderEvents = inject(OrderEventsService);
readonly orderId = input.required<number>();
protected readonly events = toSignal(
toObservable(this.orderId).pipe(
switchMap((id) => this.orderEvents.watch(id).pipe(scan((all, e) => [...all, e], [] as OrderEvent[]))),
),
{ initialValue: [] },
);
}
In the drop test above, the page showed the list "#1 Received, #2 Packed, #3 Shipped, #4 OutForDelivery" with nothing missing. A cross-origin EventSource also needs a CORS (Cross-Origin Resource Sharing) policy on the server; because we use withCredentials: true, the policy has AllowCredentials() and an explicit origin list.
The Auth Problem: EventSource Cannot Send Headers
The EventSource constructor only takes a URL (Uniform Resource Locator) and withCredentials; you cannot add an Authorization header. There are three ways out. The first is cookies: if the app already uses cookie-based authentication, withCredentials: true is enough. The second is putting the token in the query string; that is what SignalR does, and we discuss its risk (the token ending up in access logs) in the sixth post of the series. The third is to drop EventSource and read the stream yourself with fetch:
// EventSource cannot send headers; if you need a token, read the stream yourself with fetch
export async function* streamWithFetch(url: string, token: string, signal?: AbortSignal): AsyncGenerator<string> {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
signal,
});
if (!response.ok || !response.body) {
throw new Error(`SSE request failed: ${response.status}`);
}
let buffer = '';
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
const { value, done } = await reader.read();
if (done) {
return;
}
buffer += value;
// events end with a blank line; an incomplete last chunk waits for the next read
const events = buffer.split('\n\n');
buffer = events.pop() ?? '';
for (const event of events) {
const data = event
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).replace(/^ /, ''))
.join('\n');
if (data) {
yield data;
}
}
}
}
Because this file has no Angular dependency, I could run it directly with Node 24:
"Clean "
"Code "
"is "
"a "
"good "
"next "
"read "
"after "
"Refactoring. "
Clean Code is a good next read after Refactoring.
The server log said "Authorization header present: True". The price: automatic reconnection, Last-Event-ID and retry are now your responsibility. This parser is deliberately minimal; it only handles events separated by \n\n and data: lines, and ignores \r\n line endings and the id: field. In production, prefer a tested library that handles those too.
Production Notes
Watch the connection count: every open SSE stream is a request on the server and a connection in Kestrel. ASP.NET Core's kestrel.active_connections and http.server.active_requests metrics show this directly; a sudden rise is a sign that clients do not close the connection after completed, or that a proxy is cutting connections and making them reopen.
Think about what happens during a deploy. When the server shuts down, open SSE connections drop and every browser reconnects at the same moment once the retry interval passes. Keep retry at a few seconds and make sure the event log on the server side is reachable by the new process too; otherwise everyone who reconnects arrives with a Last-Event-ID at an empty log.
For long-lived connections, check the timeouts one by one: Nginx proxy_read_timeout, the idle timeout of the cloud load balancer, and the streaming support of your CDN (Content Delivery Network) if you use one. Your heartbeat interval must be shorter than the smallest of them.
Common Pitfalls
Sending the first event late. Because the headers go out with the first item, the proxy returns 504 and the browser gets onopen late. Send a heartbeat as soon as the client connects.
Writing SSE by hand and getting caught by response compression. If you write the format yourself, you have to manage Content-Encoding and buffering yourself; TypedResults.ServerSentEvents does that for you.
Not sending an id and losing events on a drop. Without EventId the browser cannot send Last-Event-ID, and everything during the drop is lost.
Not closing a finished stream on the client. When the server ends the iterator, EventSource takes it as a drop and reconnects; call close() on the last event.
Ending the Observable with an error in onerror and adding an RxJS retry. It clashes with the browser's built-in reconnection.
Opening several streams over HTTP/1.1. After the sixth connection, even the page's ordinary API calls start queueing.
A Small Experiment
★☆☆ Remove the first heartbeat line from the endpoint, connect to an empty order with curl -i -N, and see for yourself that the headers do not arrive until the first heartbeat (15 seconds).
★★☆ Add a "last 100 events" limit to OrderEventLog. If a client comes with a Last-Event-ID that is no longer in the log, send an event of type reset, and on the Angular side reload the list over HTTP when that event arrives.
★★★ Extend streamWithFetch to handle the id: and retry: fields, support \r\n line endings, and reconnect with a Last-Event-ID header when the connection drops. Test it by restarting Nginx.
In the next post we move to the framework built on top of these two options: hubs, strongly typed hubs and transport negotiation in ASP.NET Core SignalR, and connecting from Angular with @microsoft/signalr.