Server-Sent Events in ASP.NET Core and .NET 10

ASP.NET Core Minimal APIs picked up native support for Server-Sent Events in .NET 10, and it is worth pausing on why that matters. SSE gives you a one-way channel from server to client where the client subscribes and simply listens. If you have built a live dashboard, a stock ticker, or any screen that needs a steady trickle of updates without the client ever talking back, SSE is often the right tool for the job, and now the framework supports it out of the box instead of requiring a hand-rolled event-stream implementation.

SSE versus SignalR: picking the right transport

A question that comes up quickly is how this compares to SignalR. SignalR defaults to WebSockets, which is a separate protocol from HTTP and supports full bidirectional communication between server and client. That flexibility earns its keep when the client also needs to push data back, for example in a chat application or a collaborative editor. For a one-way feed, that bidirectional capability is pure overhead.

SSE rides on plain HTTP, so it works through most existing proxies and load balancers without special configuration. It also degrades more predictably when a connection drops, since the browser’s built-in EventSource API retries automatically without any extra code on your part. If your use case is genuinely one-directional, SSE gets you there with fewer moving parts.

The anatomy of an SSE endpoint

Setting up an SSE endpoint in .NET 10 needs surprisingly little code. TypedResults now exposes a ServerSentEvents method that accepts an IAsyncEnumerable<T> and an event name, and ASP.NET Core takes care of setting the text/event-stream content type, flushing each chunk to the client, and tying the stream to the request’s cancellation token.

using System.ComponentModel;
using System.Runtime.CompilerServices;
 
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<FoodService>();
builder.Services.AddHostedService<FoodServiceWorker>();
 
var app = builder.Build();
app.UseDefaultFiles().UseStaticFiles();
 
app.MapGet("/orders", (FoodService foods, CancellationToken token) =>
    TypedResults.ServerSentEvents(
        foods.GetCurrent(token),
        eventType: "order")
);
 
app.Run();

The MapGet handler above wires an /orders endpoint to a FoodService registered as a singleton, along with a background service that keeps the food selection moving. Notice that the cancellation token from the request is passed straight into GetCurrent. When the client closes the browser tab or navigates away, that token gets signaled and the enumeration stops on the server, so you are not left with orphaned loops running in the background. This is a detail that is easy to miss when hand-rolling SSE with a raw HttpResponse, and getting it wrong tends to show up later as a slow memory leak under load.

A single source of truth for every subscriber

For this example, every browser tab watching the feed should see the same value at the same time, rather than each subscriber generating its own independent stream of random data. FoodService achieves that by implementing INotifyPropertyChanged and exposing a single Current property that every enumerator reads from.

public class FoodService : INotifyPropertyChanged
{
    public FoodService()
    {
        Current = Foods[Random.Shared.Next(Foods.Length)];
    }
 
    public event PropertyChangedEventHandler? PropertyChanged;
    private static readonly string[] Foods = ["food1", "food2", "food3", "food4", "food5", "food6", "food7"];
 
    private string Current
    {
        get;
        set
        {
            field = value;
            OnPropertyChanged();
        }
    }
 
    public async IAsyncEnumerable<string> GetCurrent(
        [EnumeratorCancellation] CancellationToken ct)
    {
        while (ct is not { IsCancellationRequested: true })
        {
            yield return Current;
            var tcs = new TaskCompletionSource();
            PropertyChangedEventHandler handler = (_, _) => tcs.SetResult();
            PropertyChanged += handler;
            try
            {
                await tcs.Task.WaitAsync(ct);
            }
            finally
            {
                PropertyChanged -= handler;
            }
        }
    }
 
    public void Set()
    {
        Current = Foods[Random.Shared.Next(Foods.Length)];
    }
 
    protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

The GetCurrent method is where the real logic sits. Each call to the async iterator yields the current value immediately, then waits for the next PropertyChanged notification before yielding again. It does this by creating a TaskCompletionSource, subscribing a handler to PropertyChanged that completes the task, and awaiting it with WaitAsync(ct) so cancellation still works while the enumerator is waiting.

The finally block detaches the handler after every wait, and that cleanup matters. Forgetting it would leave one stale handler attached per active subscriber, and PropertyChanged would keep firing against enumerators that had already finished, wasting cycles and quietly leaking memory on a long-running process.

Driving updates with a background service

Something needs to change FoodService.Current on a schedule, and that is the job of FoodServiceWorker, a plain BackgroundService that ASP.NET Core starts automatically when the host runs.

public class FoodServiceWorker(FoodService foodService)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            foodService.Set();
            await Task.Delay(1000, stoppingToken);
        }
    }
}

Every second, the worker calls Set(), which assigns a new random value and raises PropertyChanged. That single change fans out to every open SSE connection at once, because all of them are awaiting the same event. If you needed per-user or per-tenant data instead of one shared feed, you would swap this pattern for a scoped or keyed service and pass an identifier through the endpoint. For a broadcast feed like a live order board or a status page, one shared singleton is simpler and cheaper than that alternative.

Subscribing from the browser

On the client side, there is no library or SDK to install. The EventSource interface has been part of the browser platform for a long time now, and it is the piece that makes SSE genuinely low-effort to consume.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<style>
    ul {
        display: flex;
        flex-direction: row;
        list-style: none;
        flex-wrap: wrap;
        width: 90%;
        gap: 1rem;
        padding: 0;
    }
 
    li {
        font-size: 2rem;
    }
</style>
<body>
 
<h1>Fast-Food Order Board</h1>
<ul id="orders">
</ul>
 
<script>
    const eventSource = new EventSource('/orders');
    const ordersList = document.getElementById('orders');
 
    eventSource.addEventListener('order', event => {
        const li = document.createElement('li');
        li.textContent = event.data;
        ordersList.appendChild(li);
    });
 
    eventSource.onerror = error => {
        console.error('EventSource failed:', error);
        eventSource.close();
    };
</script>
 
</body>
</html>

The addEventListener call listens specifically for the order event name that the server sends, which lets you multiplex several event types over one connection if you need to. Every message appends a new list item with the payload. The onerror handler is not optional in a production page. EventSource retries automatically after a dropped connection, but if the server is genuinely gone or the endpoint is misconfigured, you want to close the connection explicitly and surface that to the user instead of letting the browser retry silently in the background.

Where SSE fits, and where it does not

SSE is a strong default whenever data only flows one way. I would reach for it over SignalR for use cases like live inventory boards, build status feeds, or streaming tokens back from a large language model call, since none of those need the client to push data mid-stream. One practical limitation to keep in mind is that browsers cap concurrent HTTP/1.1 connections per domain at roughly six, so a page opening several SSE connections to the same origin can hit that ceiling quickly. Serving the app over HTTP/2 removes that constraint, since it multiplexes multiple streams over a single connection.

Also remember that EventSource does not let you attach custom headers. If your endpoint needs a bearer token, you either pass it as a query parameter, which then shows up in server logs, or authenticate through a cookie, which is usually the more sensible route for browser-hosted pages.

If the client genuinely needs to send data back on the same channel, such as in a chat feature, collaborative cursor tracking, or a multiplayer game, SignalR’s WebSocket transport remains the better fit. Building that on top of SSE would mean layering a separate HTTP call for the outbound direction and manually correlating it with the stream. SignalR also brings connection groups and automatic protocol negotiation, which pays off once you are supporting many concurrent rooms or channels. For a straightforward one-way feed, the native SSE support in .NET 10 removes a fair amount of boilerplate that earlier ASP.NET Core versions required.

The sample here is intentionally small, a feed that updates once a second, but the same shape works for anything that needs to push read-only updates to a browser: order status, build pipeline progress, or notification counts. Swap FoodService for whatever data source you actually have, and you get a working real-time feed without adding a SignalR dependency. Note that this SSE support shipped as a preview feature in .NET 10 at the time this was written, so check the current release notes before relying on it in production.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading