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

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

By

7 min read··Updated ·

aspnetcoredotnetreal-time

Server-Sent Events stream data one way, from server to client, over a standard HTTP request with a text/event-stream content type. ASP.NET Core 10 adds a native API: return Results.ServerSentEvents with an IAsyncEnumerable<T>. The browser can consume cookie-authenticated streams with native EventSource; sending bearer headers requires a fetch-based client. SignalR is still the choice when you need two-way communication.

Augment Code Review's retrieval engine pulls the exact set of files and relationships necessary for the model to reason about cross-file logic, API contracts, concurrency behavior, and subtle invariants, putting Augment 10 pts above the competition on combined recall (55%) and precision (65%). Check out Augment Code Review and catch real bugs without spamming your PRs!

Use WorkOS Radar in your app for real-time protection against bots, fraud, and free trial abuse. Radar uses device fingerprinting and advanced behavioral signals, like unknown devices, geo blocking, and impossible travel to keep your customers safe! Explore WorkOS Radar now.

Real-time updates are no longer a "nice-to-have" feature. Most modern UI applications expect live data streams of some kind from the server. For years, the go-to answer in the .NET ecosystem has been SignalR. While SignalR is incredibly powerful, it's nice to have other options for simpler use cases.

With the release of ASP.NET Core 10, we finally have a native, high-level API for Server-Sent Events (SSE). It bridges the gap between basic HTTP polling and full-duplex WebSockets via SignalR.

Why SSE Instead of SignalR?

SignalR is a powerhouse that handles WebSockets, Long Polling, and SSE automatically, providing a full-duplex (two-way) communication channel. However, it comes with a footprint: a specific protocol (Hubs), a required client-side library, and a need for "sticky sessions" or a backplane (like Redis) for scaling.

SSE is different because:

  • Unidirectional: It's designed specifically for streaming data from the server to the client.
  • Native HTTP: It's just a standard HTTP request with a text/event-stream content type. No custom protocols.
  • Automatic Reconnection: Browsers natively handle reconnections via the EventSource API.
  • Lightweight: No heavy client libraries or complex handshake logic.

The Simplest Server-Sent Events Endpoint

The beauty of the .NET 10 SSE API is its simplicity. You can use the new Results.ServerSentEvents to return a stream of events from any IAsyncEnumerable<T>. Because IAsyncEnumerable represents a stream of data that can arrive over time, the server knows to keep the HTTP connection open rather than closing it after the first "chunk" of data.

Here's a minimal example of reading order placements from a channel. This example is limited to one consumer of that channel and demonstrates the response API, not a multi-client delivery design. The channel reader must be registered in dependency injection and supplied by your application's producer. The authenticated subscription example below replaces this endpoint for user-specific orders:

app.MapGet("orders/realtime", (
	ChannelReader<OrderPlacement> channelReader,
	CancellationToken cancellationToken) =>
{
	// 1. ReadAllAsync returns an IAsyncEnumerable
	// 2. Results.ServerSentEvents tells the browser: "Keep this connection open"
	// 3. New data is pushed to the client as soon as it enters the channel
	return Results.ServerSentEvents(
        channelReader.ReadAllAsync(cancellationToken),
        eventType: "orders");
});

When a client hits this endpoint:

  1. The server sends a Content-Type: text/event-stream header.
  2. The connection stays active and idle while waiting for data.
  3. As soon as your application pushes an order into the Channel, the IAsyncEnumerable yields that item, and .NET immediately flushes it down the open HTTP pipe to the browser.

It's an incredibly efficient way to handle "push" notifications without the overhead of a stateful protocol.

I'm using a Channel here as a means to an end. In a real application, you might have a background service that listens to a message queue (like RabbitMQ or Azure Service Bus) or a database change feed, and routes events into a separate channel for each connection.

A .NET channel has competing readers. An item goes to one reader, not every reader. If you inject the same reader into multiple requests, those requests divide the events between them.

Handling Missed Events

The simple endpoint we just built is great, but it has one weakness: it's missing resilience.

One of the biggest challenges with real-time streams is connection drops. By the time the browser automatically reconnects, several events might have already been sent and lost. To solve this, SSE has a built-in mechanism: the Last-Event-ID header. When a browser reconnects, it sends this ID back to the server.

In .NET 10, we can use the SseItem<T> type to wrap our data with metadata like IDs and retry intervals.

Assign the ID when publishing, before distributing the event to subscribers. Keep the event in a replay store, then use the same ID when sending it to every authorized connection. Adding events to a buffer inside an SSE request would make retention depend on a reader being connected.

Replay also needs a coordinated handoff to live delivery. For example, register the connection's live queue and capture a replay boundary under the same dispatcher lock used by publishers. Replay retained events through that boundary, then drain the queued events after it. Reading a buffer and only then subscribing leaves a window where events can be lost.

The subscription contract in the next section puts this responsibility in one place. If a cursor is older than retained history, return an explicit resync response before starting the stream and have the client fetch a fresh snapshot. An in-memory replay store is limited to one process and loses its history on restart. Multiple application instances need shared retention and a pub/sub mechanism that reaches every instance with interested connections.

Filtering Server-Sent Events by User

SSE endpoints use standard ASP.NET Core authentication and authorization, but the client determines how credentials reach the server:

  • Native EventSource with cookies: Applicable same-origin cookies are sent automatically. Cross-origin cookies require withCredentials: true, an explicit allowed origin with credentialed CORS, and browser cookie policies that permit those cookies.
  • Bearer authentication: Native EventSource has no custom-header option. To send a JWT in Authorization, use a fetch-based SSE client. withCredentials does not add a bearer token.

For user-specific orders, route events before consumption, using the authenticated user context. Filtering a shared reader after consumption can discard user A's order when user B reads it first. Even a single channel per user is insufficient if both of that user's tabs need every event.

Use one subscription per connection. The following is an application-defined contract, not a built-in .NET service or a complete dispatcher implementation:

using System.Net.ServerSentEvents;

public interface IOrderSubscriptions
{
    IAsyncEnumerable<SseItem<OrderPlacement>> Subscribe(
        string userId,
        string? lastEventId,
        CancellationToken cancellationToken);
}

Its implementation must allocate a separate bounded channel for each subscription, register it under the authenticated user ID, and copy each published order to every active subscription for that user. It must coordinate replay as described above and unregister and complete the channel in a finally block when enumeration ends or cancellation occurs. Choose a slow-consumer policy: disconnect and require replay or resync when a queue fills, rather than silently dropping orders or growing memory without a limit.

Once that service and your authentication scheme are registered, the endpoint becomes:

using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;

app.MapGet("/orders/realtime/with-replays", (
    HttpContext context,
    IOrderSubscriptions subscriptions,
    [FromHeader(Name = "Last-Event-ID")] string? lastEventId,
    CancellationToken cancellationToken) =>
{
    // Configure this claim mapping for your cookie or bearer scheme.
    var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
    if (string.IsNullOrWhiteSpace(userId))
    {
        return Results.Forbid();
    }

    return Results.ServerSentEvents(
        subscriptions.Subscribe(userId, lastEventId, cancellationToken));
})
.RequireAuthorization();

Each returned SseItem<OrderPlacement> must have event type orders and its original EventId. Authorize replay against the same user identity; an event ID is a cursor, not permission to read someone else's orders. An in-memory dispatcher only reaches connections in its own process. Before shipping an implementation, test two different users, two connections for the same user, reconnect replay, queue overflow, and disconnect cleanup.

Consuming Server-Sent Events in JavaScript

For the same-origin cookie-authenticated endpoint, you don't need a client package. The browser's native EventSource API handles parsing and reconnection, including Last-Event-ID after receiving an ID.

const eventSource = new EventSource('/orders/realtime/with-replays');

// Listen for the specific 'orders' event type we defined in C#
eventSource.addEventListener('orders', (event) => {
  const payload = JSON.parse(event.data);
  console.log(`New Order ${event.lastEventId}:`, payload);
});

// Do something when the connection opens
eventSource.onopen = () => {
  console.log('Connection opened');
};

// Handle generic messages (if any)
eventSource.onmessage = (event) => {
  console.log('Received message:', event);
};

// Handle errors and reconnections
eventSource.onerror = () => {
  if (eventSource.readyState === EventSource.CONNECTING) {
    console.log('Reconnecting...');
  }
};

Call eventSource.close() when the view unmounts or the user signs out. For a cross-origin cookie endpoint, construct it with { withCredentials: true } and configure credentialed CORS on the server.

If your frontend uses bearer tokens, see fetch-event-source: SSE With Bearer Tokens, Retries, and React. That client can set Authorization and parse the event stream. Plain fetch() alone does not implement SSE parsing or reconnection, and cross-origin bearer requests also need CORS to allow the Authorization header.

Summary

SSE in .NET 10 is the perfect middle ground for simple, one-way updates like dashboards, notification bells, and progress bars. It's lightweight, HTTP-native, and easy to secure using your existing middleware.

However, SignalR remains the robust, battle-tested choice for complex bi-directional communication or massive scale requiring a backplane.

The goal isn't to replace SignalR, but to give you a simpler tool for simpler jobs. Choose the lightest tool that solves your problem.

Thanks for reading.

And stay awesome!


Frequently Asked Questions

What are Server-Sent Events in ASP.NET Core?

Server-Sent Events (SSE) stream data one way, from server to client, over a standard HTTP request with a text/event-stream content type. ASP.NET Core 10 added a native, high-level API for SSE, bridging the gap between basic HTTP polling and full-duplex WebSockets via SignalR.

Should I use Server-Sent Events or SignalR?

Use SSE for simple, one-way updates like dashboards, notification bells, and progress bars. Native EventSource needs no client library for cookie-authenticated streams; bearer headers require a fetch-based client. SignalR provides two-way communication and built-in groups and scale-out integrations.

How do you create a Server-Sent Events endpoint in .NET 10?

Return Results.ServerSentEvents from a minimal API endpoint, passing an IAsyncEnumerable<T> such as ChannelReader.ReadAllAsync. The server sends a text/event-stream header, keeps the connection open, and flushes each item down the HTTP pipe as soon as it is yielded.

How do you handle missed events when an SSE connection drops?

Native EventSource reconnects and sends Last-Event-ID after receiving an event ID. Assign stable IDs when publishing, retain events, and replay authorized events after that cursor. The server must coordinate replay with a separate live subscription for each connection so events cannot fall into a gap between replay and live delivery.

Do you need a JavaScript library to consume Server-Sent Events?

Not for a stream that works with native EventSource, including cookie authentication. EventSource handles parsing and reconnection but cannot set custom Authorization headers. For bearer tokens, POST bodies, or explicit retry policies, use a fetch-based SSE client such as @microsoft/fetch-event-source.

How do you secure a Server-Sent Events endpoint?

Configure ASP.NET Core authentication, apply RequireAuthorization, and route events using the authenticated user identity. Native EventSource supports cookies but cannot set a bearer Authorization header; use a fetch-based client for that. Give each connection a separate subscription instead of filtering a shared competing-reader channel.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.