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.
Picture a railway station with a big announcement board. You sit on a bench. You do not walk to the counter every minute asking "Has my train arrived? Has my train arrived?" Instead, the station keeps announcing updates on its own. "Train 12345 is now on platform 3." You just listen. The station talks, you receive.
That is exactly what Server-Sent Events (SSE) do for a website. The server keeps one connection open and keeps announcing new things. Your browser just listens. You never press refresh.
In this guide we will learn what SSE is, when to use it, and how the brand-new support in ASP.NET Core and .NET 10 makes it very easy. We will keep the words small and the steps friendly.
What problem does SSE solve?
Normally the web works like a question and answer. Your browser asks the server something, the server replies, and the connection closes. Like sending a letter and waiting for a reply letter.
But what about a live cricket score? Or a stock price? Or a "your food is being cooked" status? With the old way, the browser has to keep asking again and again. This is called polling, and it wastes time and data.
SSE flips this. The browser opens one connection and says "keep me posted." Then the server pushes new messages down that same pipe whenever it has something. The browser listens with an open ear.
SSE versus WebSockets versus SignalR
People often mix these up. Here is the simple difference.
| Feature | Polling | SSE | WebSockets |
|---|---|---|---|
| Direction | Two-way, but slow | Server to client only | Both ways |
| Connection | New each time | One stays open | One stays open |
| Built into browser | Yes (plain fetch) | Yes (EventSource) | Yes (WebSocket) |
| Auto-reconnect | You write it | Browser does it | You write it |
| Best for | Rare checks | Live feeds, alerts | Chat, games |
The big idea: if updates flow only from server to browser, SSE is the lightest and simplest choice. You do not need a heavy library. You do not need a fancy handshake. It is just normal HTTP with a special content type called text/event-stream.
SignalR (covered in another article) is a richer tool that can use WebSockets, SSE, or polling under the hood. SSE on its own is smaller and easier when you only push one way.
How an SSE message looks
An SSE stream is just plain text sent over time. Each message is a few lines. A simple message looks like this:
event: priceUpdate
id: 42
data: {"symbol":"INFY","price":1530}
The blank line at the end tells the browser "this message is done." The event line is a label, the id line helps with reconnection, and the data line carries your actual payload. Before .NET 10, you had to write this text format by hand. Now the framework writes it for you.
Anatomy of one SSE message
Steps
event
A label like priceUpdate
id
Used to resume after a drop
data
Your real content
blank line
Marks the end of the message
The new way in .NET 10
Starting in .NET 10, ASP.NET Core added a result type built for this job. You return TypedResults.ServerSentEvents(...) and hand it an IAsyncEnumerable<T>. The base libraries also added a new System.Net.ServerSentEvents namespace and a type called SseItem<T>.
When you return this result, ASP.NET Core notices it. It quietly adds a filter that keeps the connection open instead of closing it. As your async sequence yields items one by one, the framework formats each one into the SSE text format and streams it to the browser. You only write the data; the plumbing is done for you.
SseItem<T> has four properties, and only one is required:
| Property | Required? | What it does |
|---|---|---|
Data | Yes | The payload you want to send |
EventType | No | A label so the client can tell events apart |
EventId | No | An id sent back as Last-Event-ID on reconnect |
ReconnectionInterval | No | How long the browser waits before retrying |
A first tiny example
Let us build an endpoint that streams a fake heart rate every second. We use an async method that yields numbers forever, and we wrap each one with TypedResults.ServerSentEvents.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/heart-rate", (CancellationToken ct) =>
{
async IAsyncEnumerable<int> GetHeartRate(
[EnumeratorCancellation] CancellationToken token)
{
var random = new Random();
while (!token.IsCancellationRequested)
{
yield return random.Next(60, 100);
await Task.Delay(1000, token);
}
}
return TypedResults.ServerSentEvents(GetHeartRate(ct), eventType: "heartRate");
});
app.Run();A few things to notice. The CancellationToken is important. When the browser closes the tab, the token is cancelled, the loop stops, and we do not leak a runaway task. The eventType of "heartRate" becomes the label on every message, so the browser can listen for exactly that name.
Reading the stream in the browser
The browser side is short and sweet. The built-in EventSource object opens the connection and fires an event each time a message arrives.
const source = new EventSource("/heart-rate");
source.addEventListener("heartRate", (event) => {
console.log("New heart rate:", event.data);
});
source.onerror = () => {
console.log("Connection dropped, browser will retry...");
};Notice we listen for "heartRate" because that was our eventType. If you do not set an event type, you can listen with the generic source.onmessage instead. The best part: if the network blinks and the connection drops, the browser reconnects on its own. You did not write a single line for that.
Sending rich objects, not just numbers
Real apps send objects, not plain integers. You can yield any type and let JSON serialization handle it. Here is a stream of order updates.
public record OrderUpdate(int OrderId, string Status, DateTime At);
app.MapGet("/orders/stream", (CancellationToken ct) =>
{
async IAsyncEnumerable<SseItem<OrderUpdate>> Stream(
[EnumeratorCancellation] CancellationToken token)
{
var stages = new[] { "Placed", "Cooking", "Out for delivery", "Delivered" };
var id = 0;
foreach (var stage in stages)
{
if (token.IsCancellationRequested) yield break;
var update = new OrderUpdate(101, stage, DateTime.UtcNow);
yield return new SseItem<OrderUpdate>(update, eventType: "orderUpdate")
{
EventId = (++id).ToString()
};
await Task.Delay(2000, token);
}
}
return TypedResults.ServerSentEvents(Stream(ct));
});Here we build each SseItem<OrderUpdate> ourselves so we can set an EventId. That id matters for reconnection, which we cover next.
Reconnection and resuming where you left off
Say the connection drops at message 3. When the browser reconnects, it sends a header called Last-Event-ID with the id of the last message it saw. Your server can read that header and decide to replay only the messages the client missed. This is how you avoid sending duplicates or losing updates.
Resuming after a dropped connection
Steps
Drop at id 3
Network blinks
Browser reconnects
Automatic, no code
Sends Last-Event-ID: 3
In the request header
Server replays from 4
Only the missed items
To read that header on the server, you simply accept it as a parameter or read it from the request headers, then start your stream from the next id. Keep a small buffer or log of recent events so you have something to replay.
When should you pick SSE?
SSE shines when data flows one way, from server to client, and you want something simple. Good fits include live dashboards, notification bells, progress bars for long jobs, log tailing, and streaming AI text responses token by token. In fact, streaming chat replies from a language model is a very popular use of SSE today.
SSE is not the right pick when the client also needs to send a constant stream back, like in a multiplayer game or a two-way chat where typing indicators fly both ways. For that, reach for WebSockets or SignalR.
One more practical note. A single browser is limited to about six open HTTP connections per domain over HTTP/1.1, and SSE uses one of them while open. If you serve over HTTP/2 this limit largely goes away, because many streams share one connection. So serving SSE over HTTP/2 is a smart move for busy pages.
A real example: live notifications
Let us think about a real screen you have seen many times. The little bell icon at the top of a website that shows new notifications. When a friend likes your photo or a seller ships your parcel, a small red dot appears. You did not refresh the page. The server pushed that news to you. This is a textbook job for SSE.
Imagine the backend. Somewhere in your system, an event happens. Maybe a payment was confirmed, or a comment was added. You drop that event into a channel or a queue inside your app. Your SSE endpoint reads from that channel and yields each new notification to the connected browser. The browser receives it and animates the bell. Because the connection stays open, the delay between the event and the bell is tiny, often a fraction of a second.
This pattern scales nicely for read-heavy screens. Many users can each hold one open stream, and the server only does work when there is something real to send. Compare that to polling, where a thousand idle users would hammer your server every few seconds asking "anything new?" even when nothing changed. SSE stays quiet until there is news, which saves a lot of wasted effort on both sides.
A small but important design tip: keep each notification message small. SSE is great for short, frequent updates. If you need to send a large blob of data, send only an id over SSE, then let the browser fetch the full details with a normal request. This keeps your stream snappy and your memory low.
Testing your SSE endpoint quickly
You do not even need a browser to see SSE working. The curl command line tool can connect and print the raw stream. This is a fast way to check that your endpoint streams correctly before you write any frontend code.
When you run a curl request against the endpoint, you will see the messages arrive line by line, each ending with a blank line, exactly in the format we looked at earlier. If you see all the data appear at once at the very end instead of trickling in, that is a clue that something in your pipeline is buffering, and you should hunt down what is holding the data back.
For automated tests, you can use a typed HTTP client in .NET and read the response stream as it arrives. Because the new namespace ships a parser too, you can read the incoming events back into SseItem<T> objects in your test code and assert on them. This makes it easy to prove your endpoint sends the right events in the right order without spinning up a real browser.
Common mistakes to avoid
A few traps catch beginners, so let us name them clearly.
First, always honor the CancellationToken. If you ignore it, your server keeps looping for clients who already left, and your CPU and memory slowly fill up. The token is your "the listener walked away" signal.
Second, do not buffer the whole stream. The point of SSE is to send each item as it is ready. If something in your pipeline collects everything first, the client sees nothing until the end, which defeats the purpose.
Third, mind proxies and timeouts. Some reverse proxies or load balancers cut idle connections after a while. Sending a tiny keep-alive comment line now and then keeps the pipe warm. A comment line starts with a colon and is ignored by the browser.
Fourth, remember SSE only carries text. If you need to send binary data, encode it (for example as base64) inside the data field.
Quick recap
- SSE lets the server push a stream of updates to the browser over one open HTTP connection, like a station announcement board.
- It is one-way (server to client), lightweight, and uses the
text/event-streamcontent type. - The browser's built-in
EventSourcereads the stream and reconnects automatically for free. - .NET 10 added first-class support: return
TypedResults.ServerSentEventswith anIAsyncEnumerable<T>orIAsyncEnumerable<SseItem<T>>. SseItem<T>carriesData, plus optionalEventType,EventId, andReconnectionInterval.- Use
EventIdand theLast-Event-IDheader to resume after a dropped connection. - Always respect the
CancellationToken, stream items one at a time, and prefer HTTP/2 for busy pages. - Choose SSE for one-way feeds; choose WebSockets or SignalR when both sides need to talk.
References and further reading
- TypedResults.ServerSentEvents method (Microsoft Learn)
- What's new in ASP.NET Core in .NET 10 (Microsoft Learn)
- Create responses in Minimal API applications (Microsoft Learn)
- Server-Sent Events in ASP.NET Core and .NET 10 (Khalid Abuhakmeh)
- Server-Sent Events in ASP.NET Core and .NET 10 (Milan Jovanovic)
- 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.
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.
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.