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

> ASP.NET Core 10 introduces native Server-Sent Events as a lightweight, HTTP-native alternative to SignalR for simple one-way real-time updates like dashboards and notifications.

Published: 2025-12-20. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/server-sent-events-in-aspnetcore-and-dotnet-10

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>`, and the browser consumes it with `EventSource`, no client library required.
SignalR is still the choice when you need two-way communication.

**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**](https://milanjovanovic.tech/blog/adding-real-time-functionality-to-dotnet-applications-with-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](https://dotnet.microsoft.com/en-us/apps/aspnet/signalr) is a powerhouse that handles
[WebSockets](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API), Long Polling, and
[SSE](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) 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 an SSE endpoint that streams order placements in real-time:

```csharp
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`**](https://milanjovanovic.tech/blog/lightweight-in-memory-message-bus-using-dotnet-channels) here as a means to an end.
In a real application, you might have a [**background service**](https://milanjovanovic.tech/blog/scheduling-background-jobs-with-quartz-net)
that listens to a message queue (like RabbitMQ or Azure Service Bus)
or a database change feed, and pushes new events into the channel for connected clients to consume.

## 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>`](https://learn.microsoft.com/en-us/dotnet/api/system.net.serversentevents.sseitem-1?view=net-10.0)
type to wrap our data with metadata like IDs and retry intervals.

By combining a simple in-memory **OrderEventBuffer** with the **Last-Event-ID** provided by the browser, we can "replay" missed messages upon reconnection:

```csharp {4,10,12}
app.MapGet("orders/realtime/with-replays", (
	ChannelReader<OrderPlacement> channelReader,
	OrderEventBuffer eventBuffer,
	[FromHeader(Name = "Last-Event-ID")] string? lastEventId,
	CancellationToken cancellationToken) =>
{
	async IAsyncEnumerable<SseItem<OrderPlacement>> StreamEvents()
	{
		// 1. Replay missed events from the buffer
		if (!string.IsNullOrWhiteSpace(lastEventId))
		{
			var missedEvents = eventBuffer.GetEventsAfter(lastEventId);
			foreach (var missedEvent in missedEvents)
			{
				yield return missedEvent;
			}
		}

		// 2. Stream new events as they arrive in the Channel
		await foreach (var order in channelReader.ReadAllAsync(cancellationToken))
		{
			var sseItem = eventBuffer.Add(order); // Buffer assigns a unique ID
			yield return sseItem;
		}
	}

	return TypedResults.ServerSentEvents(StreamEvents(), "orders");
});
```

## Filtering Server-Sent Events by User

Server-Sent Events is built on top of standard HTTP.
Because it is a standard `GET` request, your existing infrastructure "just works":

- **Security**: You can pass a standard [**JWT**](https://milanjovanovic.tech/blog/jwt-authentication-aspnetcore) in the `Authorization` header.
- [**User Context**](https://milanjovanovic.tech/blog/getting-the-current-user-in-clean-architecture): You can access `HttpContext.User` to extract a User ID and filter the stream.
  You only send a user the data that belongs to them.

Here's an example of an SSE endpoint that streams only the orders for the authenticated user:

```csharp {3}
app.MapGet("orders/realtime", (
	ChannelReader<OrderPlacement> channelReader,
	IUserContext userContext, // Injected context containing user metadata
	CancellationToken cancellationToken) =>
{
	// The UserId is extracted from the JWT access token by the IUserContext
	var currentUserId = userContext.UserId;

	async IAsyncEnumerable<OrderPlacement> GetUserOrders()
	{
		await foreach (var order in channelReader.ReadAllAsync(cancellationToken))
		{
			// We only yield data that belongs to the authenticated user
			if (order.CustomerId == currentUserId)
			{
				yield return order;
			}
		}
	}

	return Results.ServerSentEvents(GetUserOrders(), "orders");
})
.RequireAuthorization(); // Standard ASP.NET Core Authorization
```

Note that when you write a message to a `Channel` it's broadcast to **all** connected clients.
This isn't ideal for per-user streams.
You'll probably want to use something more robust for production.

## Consuming Server-Sent Events in JavaScript

On the client side, you don't need to install a single npm package.
The browser's native `EventSource` API handles the heavy lifting, including the "reconnect and send Last-Event-ID" logic we discussed above.

```javascript
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.data);
});

// 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...');
  }
};
```

## 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.

That's all for today. Hope this was helpful.

---

## 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: it is lightweight, HTTP-native, and needs no client library. SignalR remains the battle-tested choice for two-way communication or massive scale requiring a backplane. Choose the lightest tool that solves your problem.

### 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?

Browsers reconnect automatically and send the Last-Event-ID header. In .NET 10 you wrap events in SseItem<T> so each carries an ID, keep recent events in a buffer, and on reconnection replay everything after the last ID the client received before streaming new events.

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

No. The native EventSource API in the browser handles the connection, automatic reconnection, and sending Last-Event-ID on reconnect. You subscribe with addEventListener for the event type your endpoint defines and parse the JSON payload from event.data.

### How do you secure a Server-Sent Events endpoint?

SSE is a standard HTTP GET request, so existing infrastructure just works: pass a JWT in the Authorization header, apply RequireAuthorization, and read HttpContext.User to extract the user ID and filter the stream so each user only receives their own data.
