You need to push live data to a browser. Stock prices, deployment logs, notification badges -- the kind of thing that changes every few seconds and where polling feels wasteful. WebSockets are the usual answer, but they bring connection management, protocol upgrades, and bidirectional complexity you did not ask for. If the client just needs to listen, Server-Sent Events (SSE) have always been the lighter option: a single HTTP connection, automatic reconnection baked into the browser, and plain text you can debug with curl.

The problem was that ASP.NET Core had no opinion on SSE. You had to manually set Content-Type: text/event-stream, flush the response stream yourself, format each event with data: prefixes and double newlines, and hope you got the spec right. It worked, but it was tedious and error-prone.

.NET 10 fixes this. ASP.NET Core now ships TypedResults.ServerSentEvents() as a first-class result type, and the base class libraries add a System.Net.ServerSentEvents namespace with types for both producing and consuming events. The ceremony is gone -- you yield items, and the framework handles the wire format.

The basics: streaming from a Minimal API endpoint

At its simplest, an SSE endpoint is an IAsyncEnumerable wrapped in TypedResults.ServerSentEvents:

Program.cs
app.MapGet("/heartrate", (CancellationToken cancellationToken) =>
{
    async IAsyncEnumerable<int> StreamHeartRate(
        [EnumeratorCancellation] CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            yield return Random.Shared.Next(60, 100);
            await Task.Delay(2000, ct);
        }
    }

    return TypedResults.ServerSentEvents(
        StreamHeartRate(cancellationToken),
        eventType: "heartRate");
});

That is genuinely it. The framework sets Content-Type: text/event-stream, disables response buffering, and formats each yielded value as a spec-compliant SSE message. The eventType parameter maps to the event: field in the SSE protocol, so clients can listen for specific event categories.

On the client side, the browser's EventSource API connects and listens:

script.js
const source = new EventSource('/heartrate');
source.addEventListener('heartRate', (event) => {
    document.getElementById('bpm').textContent = event.data;
});

When the client disconnects, the CancellationToken fires, the async enumerable stops, and the server cleans up. No manual connection tracking required.

SseItem<T>: adding metadata to events

The simple overload works when you just need to stream data. When you need event IDs, custom event types per message, or reconnection hints, use SseItem<T> directly:

Program.cs
app.MapGet("/orders", (CancellationToken cancellationToken) =>
{
    async IAsyncEnumerable<SseItem<OrderUpdate>> StreamOrders(
        [EnumeratorCancellation] CancellationToken ct)
    {
        await foreach (var order in orderService.WatchNewOrders(ct))
        {
            yield return new SseItem<OrderUpdate>(order, eventType: "order-placed")
            {
                EventId = order.Id.ToString(),
                ReconnectionInterval = TimeSpan.FromSeconds(5)
            };
        }
    }

    return TypedResults.ServerSentEvents(StreamOrders(cancellationToken));
});

public record OrderUpdate(Guid Id, string CustomerName, decimal Total, DateTime PlacedAt);

The SseItem<T> struct lives in System.Net.ServerSentEvents and has four properties:

// TIP

When you pass SseItem<T> values, you do not need the eventType parameter on ServerSentEvents() itself -- each item carries its own event type. The top-level parameter is a convenience for streams where every event has the same type.

Handling reconnection with Last-Event-ID

One of SSE's best features is automatic reconnection. When a connection drops, the browser reconnects and sends a Last-Event-ID header containing the ID of the last event it received. Your endpoint can use this to resume the stream from where the client left off:

Program.cs
app.MapGet("/notifications", (
    [FromHeader(Name = "Last-Event-ID")] string? lastEventId,
    CancellationToken cancellationToken) =>
{
    async IAsyncEnumerable<SseItem<Notification>> StreamNotifications(
        string? resumeAfter,
        [EnumeratorCancellation] CancellationToken ct)
    {
        var notifications = resumeAfter is not null
            ? notificationStore.GetAfter(resumeAfter, ct)
            : notificationStore.GetAll(ct);

        await foreach (var notification in notifications)
        {
            yield return new SseItem<Notification>(notification)
            {
                EventId = notification.SequenceId,
                EventType = "notification"
            };
        }
    }

    return TypedResults.ServerSentEvents(
        StreamNotifications(lastEventId, cancellationToken));
});

This pattern requires your events to have stable, ordered identifiers. A database sequence, a timestamp, or an event store position all work well. GUIDs do not, because they are not ordered and a new client cannot request "everything after this GUID" efficiently.

// WARNING

The Last-Event-ID header is only sent on reconnection, not on the initial connection. Do not rely on it for initial state hydration -- handle the null case explicitly.

Broadcasting to multiple clients with Channel<T>

A common mistake with SSE is creating a separate data source per connection. If you are watching a database or subscribing to a message queue, you want one subscription that fans out to all connected clients. System.Threading.Channels is the natural fit:

Services/PriceHub.cs
public class PriceHub
{
    private readonly List<Channel<StockPrice>> _subscribers = [];
    private readonly Lock _lock = new();

    public ChannelReader<StockPrice> Subscribe()
    {
        var channel = Channel.CreateBounded<StockPrice>(
            new BoundedChannelOptions(100)
            {
                FullMode = BoundedChannelFullMode.DropOldest
            });

        lock (_lock) { _subscribers.Add(channel); }
        return channel.Reader;
    }

    public void Unsubscribe(ChannelReader<StockPrice> reader)
    {
        lock (_lock)
        {
            _subscribers.RemoveAll(c => c.Reader == reader);
        }
    }

    public async Task BroadcastAsync(StockPrice price)
    {
        List<Channel<StockPrice>> snapshot;
        lock (_lock) { snapshot = [.. _subscribers]; }

        foreach (var channel in snapshot)
        {
            await channel.Writer.WriteAsync(price);
        }
    }
}

The endpoint subscribes, streams, and cleans up on disconnect:

Program.cs
app.MapGet("/prices", (PriceHub hub, CancellationToken cancellationToken) =>
{
    async IAsyncEnumerable<SseItem<StockPrice>> StreamPrices(
        [EnumeratorCancellation] CancellationToken ct)
    {
        var reader = hub.Subscribe();
        try
        {
            await foreach (var price in reader.ReadAllAsync(ct))
            {
                yield return new SseItem<StockPrice>(price)
                {
                    EventType = "price-update"
                };
            }
        }
        finally
        {
            hub.Unsubscribe(reader);
        }
    }

    return TypedResults.ServerSentEvents(StreamPrices(cancellationToken));
});

// TIP

Use BoundedChannelOptions with DropOldest to prevent a slow client from causing unbounded memory growth. A slow consumer that falls behind will miss older events rather than blocking the entire pipeline.

Consuming SSE streams with SseParser

.NET 10 also adds a client-side parser in System.Net.ServerSentEvents. If your application needs to consume an external SSE stream (an AI API, a third-party webhook stream, or another internal service), you no longer need to parse the protocol by hand:

Services/ExternalStreamConsumer.cs
using var client = new HttpClient();
using var response = await client.GetAsync(
    "https://api.example.com/events",
    HttpCompletionOption.ResponseHeadersRead);

await using var stream = await response.Content.ReadAsStreamAsync();

await foreach (var item in SseParser.Create(stream).EnumerateAsync())
{
    Console.WriteLine($"Event: {item.EventType}, Data: {item.Data}");
}

The simple SseParser.Create(stream) overload parses event data as strings. For typed deserialization, pass a delegate:

Example.cs
var parser = SseParser.Create(stream, (eventType, bytes) =>
{
    return JsonSerializer.Deserialize<SensorReading>(bytes.Span);
});

await foreach (var item in parser.EnumerateAsync())
{
    ProcessReading(item.Data);
}

The parser exposes LastEventId and ReconnectionInterval properties, so you can implement reconnection logic that mirrors what the browser does natively.

SSE in controller-based APIs

While the examples above use Minimal APIs, TypedResults.ServerSentEvents works in controllers too. Return it from an action method:

Controllers/EventsController.cs
[ApiController]
[Route("api/[controller]")]
public class EventsController(IEventStream eventStream) : ControllerBase
{
    [HttpGet("stream")]
    public IResult GetStream(CancellationToken cancellationToken)
    {
        return TypedResults.ServerSentEvents(
            eventStream.Subscribe(cancellationToken),
            eventType: "event");
    }
}

The return type is IResult rather than ActionResult<T> -- this is because ServerSentEvents produces a streaming result that bypasses the normal serialisation pipeline.

Common pitfalls

Forgetting [EnumeratorCancellation]. Without this attribute on the CancellationToken parameter of your async iterator, the token will not be wired up when the framework iterates the enumerable. The stream will keep running on the server after the client disconnects, leaking resources until the method happens to check cancellation manually.

Using unbounded channels for broadcast. If one client reads slowly and you are using Channel.CreateUnbounded, the channel will buffer indefinitely. In production, a handful of slow clients can eat significant memory. Always use bounded channels with an explicit overflow strategy.

Assuming EventSource sends custom headers. The browser's EventSource API only supports GET requests and does not allow custom headers. If your SSE endpoint requires authentication via bearer tokens, you have three options: use cookie-based authentication, pass the token as a query parameter (less ideal but sometimes necessary), or use the fetch() API with ReadableStream instead of EventSource.

Reverse proxy buffering. Nginx, HAProxy, and many cloud load balancers buffer responses by default. SSE events will queue up silently and arrive in batches rather than in real time. You need to explicitly disable buffering:

nginx.conf
location /events/ {
    proxy_pass http://backend;
    proxy_buffering off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding off;
}

Not handling the null Last-Event-ID. The Last-Event-ID header is only present on reconnection attempts. If your endpoint assumes it always has a value, first-time connections will fail. Always treat it as nullable and fall back to streaming from the beginning or from the latest event.

Streaming without back-pressure. If your data source produces events faster than the HTTP connection can deliver them, you will build up an ever-growing buffer. Use bounded channels, skip stale events, or throttle the yield rate to match what the network can sustain.

When to use SSE over WebSockets and long polling

SSE occupies a specific niche. Use it when:

WebSockets remain the right choice when you need bidirectional communication (chat, collaborative editing) or binary streaming. Long polling is rarely the right choice now that SSE has first-class framework support.

Summary