SHARE:

ASP.NET Core 10: First-class Server-Sent Events with an Angular client

Introduction

For years, if you wanted to push data from the server to the client in ASP.NET Core, you essentially had two choices: SignalR or doing it yourself with Response.WriteAsync, manual Content-Type headers, and careful flushing. SignalR is great for bidirectional communication, but it’s a lot of machinery when all you need is a unidirectional stream, think live notifications, progress updates, or streaming search results.

ASP.NET Core 10 introduces first-class support for Server-Sent Events (SSE) through the new TypedResults.ServerSentEvents API. Under the hood, it relies on the SseItem<T> type from the System.Net.ServerSentEvents namespace, a BCL addition (not ASP.NET Core–specific) that was introduced in .NET 9 for client-side SSE parsing and is now reused on the server side. The framework handles the text/event-stream content type, the wire format, JSON serialization, connection keep-alive, and cancellation. You just provide an IAsyncEnumerable<T> and you’re done.

In this post, I’ll walk through a complete example: an ASP.NET Core 10 backend that streams Country events through SSE, consumed by an Angular service using the browser’s native EventSource API.

Why SSE over SignalR (or polling)

Before jumping into the code, it’s worth understanding when SSE makes sense.

SSE works over plain HTTP/1.1. It’s unidirectional (server -> client), natively supported by all modern browsers, and automatically reconnects when the connection drops. There’s no WebSocket handshake, no hub abstraction, no additional protocol to negotiate. If you’re replacing a polling loop with setInterval, SSE is almost certainly what you want.

SignalR adds bidirectional communication, RPC-style method invocation, multiple transport fallbacks, and client SDKs for non-browser platforms. It’s the right tool for chat, collaborative editing, or anything where the client talks back frequently.

The rule of thumb: if data only flows from the server to the client, SSE is simpler, lighter, and now natively supported in ASP.NET Core 10.

The backend: ASP.NET Core 10

The model and DbContext

A Country entity and a minimal EF Core context:

The service

This is the important part. The CountryService uses EF Core’s AsAsyncEnumerable() to stream rows from the database as they’re read, each country is yielded to the SSE client the moment it comes off the database cursor, without buffering the entire result set in memory first.

To keep the stream alive (which is what SSE is designed for), the service polls for new data periodically. Since an SSE connection can last hours, we can’t hold a single DbContext open the whole time, that would keep a database connection permanently occupied and let the change tracker bloat. Instead, we use IDbContextFactory<T> to create a short-lived context per polling cycle:

A few things worth calling out:

AsAsyncEnumerable() : this is what gives us true streaming. EF Core reads rows from the database one at a time through the underlying DbDataReader, and each row is yielded immediately to the SSE pipeline. For a table with thousands of rows, the client starts receiving data right away instead of waiting for the full query to complete.

IDbContextFactory<T> : the EF Core-native pattern for scenarios where the consumer outlives a normal scope (Blazor Server, BackgroundService, and long-lived SSE streams like ours). Each polling cycle gets a fresh DbContext with its own connection, created and disposed within the cycle. No long-lived connections, no change tracker bloat.

The polling loop : after streaming all available rows, the service waits 2 seconds and checks again. New rows inserted in the meantime (with Id > lastId) will be picked up on the next cycle and streamed to the client immediately.

The SSE endpoint

Here’s where ASP.NET Core 10 shines. Exposing an SSE endpoint is a single call:

No manual header management, no Response.Body.FlushAsync(), no custom middleware. The framework sets Content-Type: text/event-stream, serializes each Country to JSON, and pushes it as an SSE event with event: country in the wire format. When the client disconnects, the CancellationToken cancels and the IAsyncEnumerable terminates, the current scope is disposed and the polling loop exits cleanly.

What the wire looks like

When a client connects to /countries/stream, the response looks like this:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Transfer-Encoding: chunked

event: country
data: {"id":1,"name":"Canada","code":"CA","population":40000000}

event: country
data: {"id":2,"name":"France","code":"FR","population":68000000}

event: country
data: {"id":3,"name":"Japan","code":"JP","population":125000000}

Each event is separated by a blank line. The event: country field matches the eventType parameter we passed to ServerSentEvents. The stream stays open indefinitely, new events arrive whenever the service yields them.

The frontend: Angular

On the Angular side, we don’t need HttpClient for this. SSE is consumed through the browser’s native EventSource API. Rather than wrapping it in an RxJS Observable with manual subscription management, we can use Angular Signals for a cleaner, more idiomatic approach, no NgZone, no Subscription, no OnDestroy boilerplate:

A few things worth noting here:

addEventListener(‘country’, …) : we’re not using onmessage, because onmessage only fires for events without a named event: field. Since our server sends event: country, we must listen for that specific event type. This is a common gotcha, if you use onmessage with named events, you’ll never receive anything.

No NgZone needed : signal.update() and signal.set() have their own notification mechanism that marks consuming components as dirty, regardless of whether the call originates inside or outside Angular’s zone. Since EventSource callbacks run outside the zone, this matters: with the old Observable approach, you’d need NgZone.run() to trigger change detection. With Signals, it just works.

The onerror handler checks readyState : this is important. EventSource.onerror fires both on temporary disconnections (where readyState is CONNECTING, the browser is retrying) and on permanent failures (where readyState is CLOSED). If you blindly close the EventSource on any error, you kill the auto-reconnect mechanism on the first network blip. For a long-lived stream, that would be a problem. By checking readyState, we only surface fatal errors and let the browser handle transient ones.

connect() resets state first : the service is a singleton (providedIn: ‘root’), so it survives component destruction. If the user navigates away and comes back, connect() is called again on the same instance. Without the disconnect() + set([]) at the top, you’d leak the previous EventSource and accumulate stale data in the signal.

DestroyRef ties cleanup to the component : when the component is destroyed, destroyRef.onDestroy fires, which calls disconnect(), which closes the EventSource, which triggers the CancellationToken on the server. The full chain cleans up without the component needing to implement OnDestroy.

Using it in a component

That’s it, no OnInit, no OnDestroy, no Subscription. The inject() function resolves dependencies in the constructor’s injection context. DestroyRef belongs to the component, so its onDestroy callback fires when the component is torn down. The template reads directly from the service’s signals, and Angular re-renders automatically when they change.

A word on authentication

There’s one limitation of EventSource that you’ll hit as soon as your SSE endpoint requires authentication: EventSource does not support custom headers. You can’t send an Authorization: Bearer <token> header.

Your options:

Cookies : the simplest approach. If your API uses cookie-based authentication, EventSource sends cookies automatically (pass { withCredentials: true } to the constructor). This is the path of least resistance when your Angular app and API share the same domain or you have CORS configured with AllowCredentials.

Query string token : passing the token as ?access_token=xxx works but exposes the token in server logs, browser history, and referrer headers. Not ideal for sensitive environments.

Exemple on ASP.NET Core:

Fetch + ReadableStream : for full header control, you can replace EventSource with a fetch() call and manually parse the SSE stream from the ReadableStream. You gain custom headers but lose the built-in auto-reconnect. Libraries like fetch-event-source provide this with a nicer API.

For public streams or cookie-authenticated apps, EventSource is fine. For bearer token scenarios, evaluate the trade-offs based on your security requirements.

Going further: SseItem and scaling out

The example above uses the simple overload of TypedResults.ServerSentEvents that takes an IAsyncEnumerable<T>. If you need event IDs (for resume-on-reconnect) or control over the reconnection interval, use the SseItem<T> overload instead:

With event IDs in place, the browser will send a Last-Event-ID header on reconnect. You can read it from HttpRequest.Headers[“Last-Event-ID”] and skip events the client already received.

For scenarios with a single event source feeding many SSE clients simultaneously (e.g. a Kafka consumer broadcasting to all connected browsers), you’d typically introduce a Channel<T> per subscriber with a BackgroundService as the producer. That fan-out pattern is worth a separate post, but the SSE endpoint itself stays the same, it’s still just an IAsyncEnumerable<T> fed into TypedResults.ServerSentEvents.

Conclusion

ASP.NET Core 10 turns SSE from a manual plumbing exercise into a one-liner. You provide an IAsyncEnumerable<T>, the framework handles the rest, headers, serialization, connection management. Pair it with EF Core’s AsAsyncEnumerable() and each row hits the client as it’s read from the database, without buffering the entire result set.

On the Angular side, the browser’s native EventSource API does the heavy lifting. Pairing it with Angular Signals eliminates the need for NgZone, Observable, and Subscription boilerplate, just be careful not to kill the auto-reconnect on transient errors, and be aware that EventSource doesn’t support custom authentication headers.

For scenarios where data flows from server to client, live feeds, progress tracking, notifications, streaming AI responses, this is now the simplest path available in .NET.

For the implementation details, see the original API proposal on GitHub and the official documentation.

Written by

anthonygiretti

Anthony is a specialist in Web technologies (14 years of experience), in particular Microsoft .NET and learns the Cloud Azure platform. He has received twice the Microsoft MVP award and he is also certified Microsoft MCSD and Azure Fundamentals.