Server-Sent Events in ASP.NET Core and .NET 10
Learn Server-Sent Events in ASP.NET Core .NET 10 with TypedResults.ServerSentEvents and IAsyncEnumerable, explained simply for beginners.
Imagine you are at a railway station in India waiting for your train. There is a big announcement speaker on the platform. You do not run to the station master every minute to ask "Has my train arrived? Has my train arrived?" Instead, you just stand there. When something happens, the speaker announces it: "Train number 12345 is arriving on platform 3." The information comes to you, the moment it is ready.
Server-Sent Events, or SSE, work just like that platform speaker. Your browser opens one connection to the server and then simply listens. Whenever the server has news, it announces it down that same open line. Your page updates by itself. No refresh button. No asking again and again.
In .NET 10 this got really easy. There is now a built-in helper called TypedResults.ServerSentEvents. You do not need any extra library. In this guide we will learn what SSE is, how it works, and how to build a small live endpoint step by step. We will keep the words simple and the steps small.
What problem does SSE solve?
Think about how a normal website works. Your browser asks the server a question, and the server answers. Like sending a letter by post and waiting for the reply to come back. This is fine for most pages. But it is a problem when data keeps changing.
Say you are watching a live cricket score. With the normal way, your browser has to keep asking "Any new runs? Any new runs?" every few seconds. This is called polling. It is noisy, slow, and wasteful. Most of the time the answer is "nothing new yet", but you still paid for the round trip.
SSE flips this around. The browser opens one connection and keeps it open. The server then pushes new data down that line whenever it wants. The browser does not ask repeatedly. It just waits and listens, like you waiting for the platform announcement.
How SSE works under the hood
SSE is not magic. It is built on plain old HTTP, the same thing every website uses. The trick is that the server does not close the connection after the first reply. It holds the line open and keeps writing small text messages into it.
Each message follows a tiny, simple format defined by the web standard. A message can have a few fields:
| Field | Meaning | Required? |
|---|---|---|
data | The actual payload you want to send | Yes |
event | A name for the event type, like heartrate | No |
id | A unique id for this event, used for resume | No |
retry | How long the browser should wait before reconnecting | No |
The server sets a special response header, Content-Type: text/event-stream, so the browser knows this is an event stream and not a normal page. Then each event is just text ending with a blank line. The browser's built-in EventSource reads these and raises events for your JavaScript code.
The SSE handshake
Steps
Open
Browser opens EventSource
Stream
Server pushes text events
Reconnect
Browser auto-retries if dropped
The best part is that the browser does a lot of work for you. If the connection drops, the EventSource reconnects on its own. It even remembers the last event id it saw and sends it back in a Last-Event-ID header. This lets a smart server pick up right where it left off.
What changed in .NET 10
Before .NET 10, you could still do SSE in ASP.NET Core, but you had to do the plumbing yourself. You wrote to the response stream by hand, set the headers, formatted each message with the right data: prefix and blank lines, and flushed the buffer. It worked, but it was easy to get wrong.
.NET 10 adds first-class support. There is a new helper, TypedResults.ServerSentEvents (and Results.ServerSentEvents), that takes an IAsyncEnumerable<T> and turns it into a proper SSE stream. The framework handles the headers, the formatting, and keeping the connection alive. The base class libraries also added a System.Net.ServerSentEvents namespace with types for building and reading events.
Why does IAsyncEnumerable fit so well here? Because it represents a stream of values that arrive over time. Each time you yield return a new value, the server writes one more event. The connection stays open because the sequence is not finished yet. This is a perfect match for the idea of SSE.
Your first SSE endpoint
Let's build the smallest possible example. We will stream a fake heart rate that changes every second. First, we need a method that produces values over time using IAsyncEnumerable.
// A stream of heart rate numbers, one per second.
static async IAsyncEnumerable<int> GetHeartRateAsync(
[EnumeratorCancellation] CancellationToken ct)
{
var random = new Random();
while (!ct.IsCancellationRequested)
{
// Pretend we read a sensor: a number between 60 and 100.
yield return random.Next(60, 100);
await Task.Delay(TimeSpan.FromSeconds(1), ct);
}
}Notice the CancellationToken. When the browser closes the tab or the connection drops, ASP.NET Core cancels this token. That stops our loop cleanly, so we do not waste server resources sending data to nobody.
Now we wire it into a minimal API endpoint. This is the part that became one line in .NET 10.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/heart-rate", (CancellationToken ct) =>
TypedResults.ServerSentEvents(
GetHeartRateAsync(ct),
eventType: "heartRate"));
app.Run();That is the whole server. No middleware. No package. No manual header setting. TypedResults.ServerSentEvents takes our async stream and the event name, and it does the rest. Every number our method yields becomes one SSE event named heartRate.
Request to live stream
Steps
GET
Browser calls /heart-rate
Stream
Server keeps line open
Push
One event per second
Close
Token cancels on disconnect
Sending rich objects, not just numbers
Sending a single number is fine for a demo, but real apps send structured data. The good news is that TypedResults.ServerSentEvents can serialize objects to JSON for you. You just yield your own type.
public record StockTick(string Symbol, decimal Price, DateTime At);
static async IAsyncEnumerable<StockTick> GetTicksAsync(
[EnumeratorCancellation] CancellationToken ct)
{
var random = new Random();
decimal price = 100m;
while (!ct.IsCancellationRequested)
{
price += random.Next(-5, 6);
yield return new StockTick("INFY", price, DateTime.UtcNow);
await Task.Delay(TimeSpan.FromSeconds(2), ct);
}
}
app.MapGet("/stocks", (CancellationToken ct) =>
TypedResults.ServerSentEvents(GetTicksAsync(ct), eventType: "stock"));Each StockTick becomes a JSON object in the data field of the event. On the browser side, your JavaScript can parse that JSON and update the page. The framework picks the system JSON serializer, so the same naming rules your API already uses apply here too.
Controlling each event with SseItem
Sometimes you want more control. Maybe you want to give each event an id so the browser can resume after a drop, or set a custom event type per message. For that, you yield SseItem<T> values instead of plain values.
SseItem has four useful properties. Only Data is required.
| Property | What it does |
|---|---|
Data | The payload to send to the client |
EventType | The named event the browser listens for |
EventId | A unique id, returned later as Last-Event-ID |
ReconnectionInterval | How long the browser waits before retrying |
Here is how you set an id on every event, which makes reconnection-resume possible.
static async IAsyncEnumerable<SseItem<StockTick>> GetItemsAsync(
[EnumeratorCancellation] CancellationToken ct)
{
long counter = 0;
var random = new Random();
decimal price = 100m;
while (!ct.IsCancellationRequested)
{
price += random.Next(-5, 6);
var tick = new StockTick("INFY", price, DateTime.UtcNow);
yield return new SseItem<StockTick>(tick)
{
EventId = (++counter).ToString(),
EventType = "stock"
};
await Task.Delay(TimeSpan.FromSeconds(2), ct);
}
}Listening from the browser
A .NET endpoint is only half the story. On the front end, the browser has a built-in tool called EventSource. You give it the URL, and it opens the connection and reconnects for you. You only write the small handler that reacts to each message.
const source = new EventSource("/stocks");
// Listen for our named "stock" events.
source.addEventListener("stock", (e) => {
const tick = JSON.parse(e.data);
console.log(`${tick.symbol}: ${tick.price}`);
});
source.onerror = () => {
// EventSource will retry on its own; this is just for logging.
console.log("Connection hiccup, browser will reconnect.");
};Notice we used addEventListener("stock", ...) because we named our events stock. If you do not set an event type, the messages arrive as the default message event, and you would use source.onmessage instead. That small detail trips up many beginners, so it is worth remembering.
When should you use SSE?
SSE is wonderful, but it is not the answer to everything. It is one-way: server to client only. The browser cannot send messages back up the same SSE line. For things where the client must also push data live, like a two-player game or a typing-indicator chat, you may want full duplex.
Here is a simple way to choose.
| Need | Good fit |
|---|---|
| Live scores, notifications, logs, dashboards | SSE |
| Server pushes only, you want simplicity | SSE |
| Two-way live messaging, presence, games | SignalR or WebSockets |
| One-time request and response | Normal HTTP, no streaming |
A handy rule: if your data flows mostly down from the server, start with SSE. It is plain HTTP, it works through most proxies and firewalls, and now in .NET 10 it is almost no code. If you truly need rich two-way traffic with automatic transport fallback, reach for SignalR instead. Note that some popular libraries like MediatR and MassTransit have moved to commercial licensing, but SSE in ASP.NET Core needs none of that. It is part of the framework and free.
Choosing your tool
Steps
One-way?
Server pushes only: use SSE
Two-way?
Both sides talk: SignalR
Simple?
Just a reply: plain HTTP
Common mistakes to avoid
A few small things catch people out when they first try SSE. Keep these in mind.
First, always respect the CancellationToken. If you ignore it, your server keeps generating events for browsers that already left. Over time this leaks memory and CPU. Passing the token into Task.Delay and checking it in your loop fixes this.
Second, watch out for response buffering in front of your app. Some reverse proxies, like an older nginx config, buffer responses and break the live feel. SSE needs the data to flow through immediately. You usually fix this by telling the proxy not to buffer the text/event-stream content type.
Third, remember the connection limit in older browsers when using plain HTTP/1.1. A browser only allows a handful of connections to the same host. If you open many SSE streams on one page over HTTP/1.1, you can run out. Using HTTP/2, which multiplexes many streams over one connection, removes this worry. Most modern hosting uses HTTP/2 already.
A note on scaling
SSE keeps a connection open per client. If you have ten thousand viewers, that is ten thousand open connections. Modern ASP.NET Core handles many connections well because it uses async I/O, so threads are not blocked just sitting and waiting. Still, plan for it. Put your app behind a load balancer that understands long-lived connections, and use HTTP/2 to keep the per-connection cost low. For very large fan-out, a message broker or a service like Azure SignalR can help spread the load, even if you keep SSE as the wire format to the browser.
Quick recap
- SSE lets the server push live updates to the browser over one open HTTP connection, like a platform announcement speaker.
- It is one-way: server to client. The browser only listens.
- .NET 10 added
TypedResults.ServerSentEvents, which turns anyIAsyncEnumerable<T>into a proper SSE stream with no extra package. - Use
SseItem<T>when you need to set theEventId,EventType, or retry interval per message. - The browser's
EventSourcereconnects automatically and sendsLast-Event-IDso you can resume. - Always honor the
CancellationTokenso you stop work when a client disconnects. - Choose SSE for one-way streams like scores, logs, and notifications. Choose SignalR or WebSockets when you need two-way live traffic.
References and further reading
- TypedResults.ServerSentEvents Method (Microsoft Learn)
- What's new in ASP.NET Core in .NET 10 (Microsoft Learn)
- Server-Sent Events in ASP.NET Core and .NET 10 (Milan Jovanovic)
- Server-Sent Events in ASP.NET Core and .NET 10 (Khalid Abuhakmeh)
- Real-Time Server-Sent Events in ASP.NET Core and .NET 10 (Anton Martyniuk)
Related Posts
Adding Real-Time Functionality to .NET Apps with SignalR
Learn ASP.NET Core SignalR step by step: hubs, clients, groups, and scaling with Redis or Azure, explained for absolute beginners.
Real-Time Server-Sent Events in ASP.NET Core and .NET 10
Learn Server-Sent Events (SSE) in ASP.NET Core and .NET 10 with the new TypedResults.ServerSentEvents API, explained simply for beginners.
Scaling SignalR With a Redis Backplane in ASP.NET Core
Learn how a Redis backplane lets your ASP.NET Core SignalR app run on many servers so every connected user still gets every real-time message.
Solving Distributed Cache Invalidation with Redis and HybridCache
Learn how Redis and HybridCache solve distributed cache invalidation in ASP.NET Core with tags, backplanes, and a simple kitchen-counter analogy.
How I Optimized an API Endpoint to Make It 15x Faster
A simple, step-by-step story of how I made a slow ASP.NET Core API endpoint 15x faster using EF Core projection, AsNoTracking, paging, and indexes.
Scheduling Background Jobs with Quartz.NET in ASP.NET Core
Learn Quartz.NET step by step in ASP.NET Core: jobs, triggers, cron schedules, dependency injection, and database persistence, explained for beginners.