DEV Community

Cover image for Server-Sent Events in .NET 10: Finally, a Native Solution
Mashrul Haque
Mashrul Haque

Posted on • Originally published at easyappdev.com

Server-Sent Events in .NET 10: Finally, a Native Solution

There's a specific kind of frustration that comes from writing code you know is correct but fundamentally wrong. Last fall, I shipped a live notification system using a polling loop that hit the database every three seconds. It worked. Users got their updates. But every time I looked at that setInterval in the browser console, I felt a little sick.

Then .NET 10 shipped with native Server-Sent Events support.

Microsoft finally added first-class SSE to .NET 10. Not a third-party package. Not a workaround. Actual, official API for real-time server push.

What Changed in .NET 10

Before .NET 10, if you wanted SSE in ASP.NET Core, you had three options. Write your own implementation using Response.WriteAsync() and careful header management. Use a third-party library. Or just pick SignalR and move on.

I've done all three.

None felt right.

.NET 10 introduces the System.Net.ServerSentEvents namespace and a clean TypedResults API:

app.MapGet("/heartrate", async () =>
{
    return TypedResults.ServerSentEvents(GetHeartRateData());
});

async IAsyncEnumerable<SseItem<int>> GetHeartRateData()
{
    while (true)
    {
        var heartRate = Random.Shared.Next(60, 100);
        yield return new SseItem<int>(heartRate)
        {
            EventType = "heartrate",
            EventId = DateTime.UtcNow.Ticks.ToString()
        };

        await Task.Delay(1000);
    }
}
Enter fullscreen mode Exit fullscreen mode

That's it. Framework handles Content-Type headers, keeps connections alive, formats messages according to the HTML spec.

You focus on the data.

Why SSE Matters Now

Server-Sent Events have been lurking in the web platform since 2009. Lived in WebSocket's shadow for years.

Then OpenAI started streaming ChatGPT responses. Suddenly everyone cared about one-way server push again.

SSE excels at exactly one thing: server pushes data to clients. No client-to-server chatter. No complex protocols.

Just events flowing downstream.

I used SSE for a stock ticker last year (before .NET 10). Client code was five lines:

const eventSource = new EventSource('/stocks');
eventSource.onmessage = (event) => {
    console.log('New stock price:', event.data);
};
Enter fullscreen mode Exit fullscreen mode

Simple.

Browsers handle reconnection automatically.

Problem was always the server side.

The Old Way (Manual Implementation)

Let me show you what we used to write. This is real code from a project I worked on in .NET 8:

app.MapGet("/events", async (HttpContext context) =>
{
    context.Response.Headers.ContentType = "text/event-stream";
    context.Response.Headers.CacheControl = "no-cache";
    context.Response.Headers.Connection = "keep-alive";

    await context.Response.Body.FlushAsync();

    while (!context.RequestAborted.IsCancellationRequested)
    {
        var data = $"data: {DateTime.UtcNow}\n\n";
        await context.Response.WriteAsync(data);
        await context.Response.Body.FlushAsync();
        await Task.Delay(1000);
    }
});
Enter fullscreen mode Exit fullscreen mode

It works. But look at what you're managing: headers, flushing, message formatting, cancellation tokens.

Miss one detail and clients disconnect randomly or messages arrive malformed.

I once forgot the double newline after the data field.

Spent an hour debugging why Chrome wouldn't fire onmessage events.

The spec requires two newlines. Of course.

.NET 10's Approach

The new API feels intentional. You return TypedResults.ServerSentEvents() with an IAsyncEnumerable<SseItem<T>>. Framework serializes T to JSON by default.

public record StockPrice(string Symbol, decimal Price, DateTime Timestamp);

app.MapGet("/stocks/{symbol}", (string symbol) =>
{
    return TypedResults.ServerSentEvents(StreamStockPrices(symbol));
});

async IAsyncEnumerable<SseItem<StockPrice>> StreamStockPrices(string symbol)
{
    await foreach (var price in stockService.SubscribeToSymbol(symbol))
    {
        yield return new SseItem<StockPrice>(price)
        {
            EventType = "price-update",
            EventId = Guid.NewGuid().ToString()
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

SseItem has four properties. Data (your actual payload), EventType, EventId, and ReconnectionInterval. Only Data is required. Framework handles the rest—formatting, serialization, connection management.

For more details on the TypedResults.ServerSentEvents() API, check the official ASP.NET Core 10.0 documentation.

When to Use SSE (and When Not To)

I get asked this constantly. "Why not just use SignalR?"

SignalR is excellent for bidirectional communication. Chat applications, collaborative editing, anything where clients talk back frequently. But it's heavier. More moving parts. (If you're working with Blazor Server, you might also want to understand how Blazor handles reconnection scenarios.)

SSE shines when:

  • Server pushes updates to clients (stock tickers, live scores, monitoring dashboards)
  • You want dead simple client code
  • HTTP/2 is available (multiple SSE connections over one TCP connection)
  • You're streaming AI responses like OpenAI does
  • You don't need clients sending data constantly

WebSockets when you need truly bidirectional, low-latency communication. Games. Video chat. Stuff where clients talk back constantly.

SignalR when you want the abstraction—automatic fallback, RPC-style methods, multiple client SDKs.

No universal answer here. I've shipped production systems with all three.

SseParser for Consuming SSE Streams

The new namespace includes more than just SseItem<T>. There's SseParser<T> for when you're consuming SSE from other services.

Before .NET 10, calling an external SSE API meant manual parsing:

// The old way
using var client = new HttpClient();
using var response = await client.GetAsync("https://api.example.com/stream",
    HttpCompletionOption.ResponseHeadersRead);

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

while (!reader.EndOfStream)
{
    var line = await reader.ReadLineAsync();
    // Parse SSE format manually (data:, event:, id:, etc.)
    // Handle multi-line data
    // Track state across lines
    // Hope you got the spec right
}
Enter fullscreen mode Exit fullscreen mode

Now you use SseParser:

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

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

var parser = SseParser.Create(stream, (eventType, bytes) =>
{
    var json = Encoding.UTF8.GetString(bytes.Span);
    return JsonSerializer.Deserialize<MyDataType>(json);
});

await foreach (var item in parser.EnumerateAsync())
{
    Console.WriteLine($"Event: {item.EventType}, Data: {item.Data}");
}
Enter fullscreen mode Exit fullscreen mode

Parser handles spec compliance. Multi-line data fields, retry intervals, last event IDs. All the edge cases you'd otherwise spend days debugging.

Also exposes LastEventId and ReconnectionInterval for reconnection scenarios.

Real-World Example: Streaming AI Responses

Here's something I built last month. A wrapper around OpenAI's streaming API that exposes results as SSE to a web frontend:

app.MapPost("/ai/chat", async (ChatRequest request) =>
{
    return TypedResults.ServerSentEvents(StreamChatResponse(request.Message));
});

async IAsyncEnumerable<SseItem<string>> StreamChatResponse(string userMessage)
{
    var openAi = new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_KEY"));

    var options = new ChatCompletionOptions
    {
        Messages = { new UserChatMessage(userMessage) },
        Model = "gpt-4",
        Stream = true
    };

    await foreach (var chunk in openAi.GetChatCompletionStreamingAsync(options))
    {
        var content = chunk.ContentUpdate.FirstOrDefault()?.Text;
        if (!string.IsNullOrEmpty(content))
        {
            yield return new SseItem<string>(content)
            {
                EventType = "token"
            };
        }
    }

    yield return new SseItem<string>("[DONE]")
    {
        EventType = "complete"
    };
}
Enter fullscreen mode Exit fullscreen mode

On the client side, you get that satisfying token-by-token rendering ChatGPT made famous.

Note that EventSource only supports GET requests, so you'd need fetch() for POST or handle the message via query params:

// Option 1: Use fetch with streaming (more flexible)
const response = await fetch('/ai/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ message: 'Explain quantum computing' })
});

const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    const chunk = decoder.decode(value);
    document.getElementById('response').textContent += chunk;
}

// Option 2: Use EventSource with GET and query params
const eventSource = new EventSource('/ai/chat?message=' + encodeURIComponent('Explain quantum computing'));

eventSource.addEventListener('token', (e) => {
    document.getElementById('response').textContent += JSON.parse(e.data);
});

eventSource.addEventListener('complete', () => {
    eventSource.close();
});
Enter fullscreen mode Exit fullscreen mode

Works beautifully. No polling. No WebSocket overhead.

Just a persistent HTTP connection streaming text.

Error Handling and Connection Management

Client disconnections bit me early. User closes their browser tab, your server-side enumerable keeps running forever.

Wire up the cancellation token:

app.MapGet("/notifications", async (CancellationToken ct) =>
{
    return TypedResults.ServerSentEvents(StreamNotifications(ct));
});

async IAsyncEnumerable<SseItem<Notification>> StreamNotifications(
    [EnumeratorCancellation] CancellationToken ct)
{
    while (!ct.IsCancellationRequested)
    {
        var notification = await notificationService.WaitForNextAsync(ct);
        yield return new SseItem<Notification>(notification);
    }
}
Enter fullscreen mode Exit fullscreen mode

[EnumeratorCancellation] ensures the token flows through. Otherwise you leak resources when clients disconnect.

Learned this watching memory usage climb during load testing. Classic.

Performance Considerations

SSE connections are long-lived HTTP requests. Each client holds one open. Matters at scale.

ASP.NET Core handles async I/O efficiently, but you still need connection limits. Default Kestrel config can handle thousands of concurrent SSE connections on modest hardware. I've tested 5,000 on a 2-core Azure B2s instance. No issues. (For more on ASP.NET Core performance tuning, see optimizing Kestrel for production.)

But.

If you're broadcasting the same data to many clients, don't create a separate enumerable per connection. Use a shared source with fan-out:

public class StockBroadcaster
{
    private readonly List<Channel<StockPrice>> _subscribers = new();

    public async IAsyncEnumerable<SseItem<StockPrice>> Subscribe()
    {
        var channel = Channel.CreateUnbounded<StockPrice>();
        _subscribers.Add(channel);

        try
        {
            await foreach (var price in channel.Reader.ReadAllAsync())
            {
                yield return new SseItem<StockPrice>(price);
            }
        }
        finally
        {
            _subscribers.Remove(channel);
        }
    }

    public async Task BroadcastPrice(StockPrice price)
    {
        foreach (var channel in _subscribers)
        {
            await channel.Writer.WriteAsync(price);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Single background service fetches stock prices, writes to all subscriber channels. Way more efficient than N database queries or N API calls.

Deployment and Proxies

SSE works over regular HTTP. Good and bad.

Good: passes through firewalls and proxies that block WebSockets. Bad: some proxies buffer responses and break streaming.

I ran into this with nginx. The default configuration buffers responses for performance. For SSE, you need:

location /api/ {
    proxy_pass http://localhost:5000;
    proxy_buffering off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding off;
}
Enter fullscreen mode Exit fullscreen mode

Without proxy_buffering off, nginx holds data until buffers fill. Your real-time events arrive in 10-second bursts.

Confusing as hell to debug.

Azure App Service and AWS Application Load Balancer support SSE out of the box. Just verify your CDN or reverse proxy isn't buffering. (Deploying to Azure? Check out best practices for ASP.NET Core on Azure App Service.)

Browser Support and Fallbacks

EventSource API is supported everywhere except IE11. If you still support IE (my condolences), you need a polyfill or fallback to long polling.

Edge case: browser connection limits. Older HTTP/1.1 browsers cap you at 6 connections per domain. Each SSE stream counts as one. HTTP/2 multiplexing fixes this.

Haven't worried about connection limits since 2019. But it exists.

Gotcha: EventSource doesn't support custom headers. Need authentication? Put the token in the URL query string or use a cookie.

Not ideal, but the spec is what it is.

const eventSource = new EventSource('/notifications?token=abc123');
Enter fullscreen mode Exit fullscreen mode

Or set a cookie before opening the connection:

document.cookie = "auth=abc123; path=/";
const eventSource = new EventSource('/notifications');
Enter fullscreen mode Exit fullscreen mode

I prefer cookies for auth. Feels less sketchy than tokens in URLs.

Comparing to Other .NET Versions

Stuck on .NET 8 or .NET 9? You can still use SSE. Just not with the nice TypedResults API. (Considering upgrading? Here's what's new in .NET 10 beyond SSE.)

You'll manually set headers and write formatted strings. I showed the manual approach earlier. Works fine. Full control. But also full responsibility for getting the format right.

.NET 10's abstraction is thin. You're not giving up control. Just delegating tedious spec compliance to the framework.

Worth noting: System.Net.ServerSentEvents is actually available in .NET 9 as a preview feature:

<PackageReference Include="System.Net.ServerSentEvents" Version="9.0.0-preview" />
Enter fullscreen mode Exit fullscreen mode

It's marked preview for a reason. API surface changed between .NET 9 preview and .NET 10 release. Wait for .NET 10 unless you enjoy migration work.

What I Wish Existed (But Doesn't)

The new API is solid. But there are gaps.

Client reconnection with last event ID isn't automatic. Browser sends a Last-Event-ID header on reconnect. You wire it up yourself:

app.MapGet("/events", (HttpContext context) =>
{
    var lastEventId = context.Request.Headers["Last-Event-ID"].FirstOrDefault();
    return TypedResults.ServerSentEvents(StreamFrom(lastEventId));
});
Enter fullscreen mode Exit fullscreen mode

I'd love a built-in way to handle this. Maybe in .NET 11.

Also missing: broadcast helpers. The fan-out pattern I showed earlier should be in the framework. Broadcasting to multiple clients is common enough.

Still don't understand why EventSource can't send custom headers. Browser spec issue, not .NET. But it complicates authentication.

Final Thoughts

.NET 10's Server-Sent Events support feels like what should have existed five years ago.

Better late than never.

For complete API documentation, see ASP.NET Core 10.0 release notes and the System.Net.ServerSentEvents API reference.

If you're building real-time features where the server pushes data to clients, try SSE before reaching for SignalR or WebSockets. Simplicity is refreshing.

I replaced a 200-line custom SSE implementation with 30 lines using TypedResults. New code is easier to read, easier to test, harder to get wrong.

That's what good framework design looks like.


Author: Mashrul Haque
LinkedIn: https://www.linkedin.com/in/mashrul-haque-7ab22934/
GitHub: https://github.com/mashrulhaque
Twitter/X: https://x.com/mashrulthunder

Top comments (1)

Collapse
 
nuruddin106 profile image
Nur Uddin Ahmed

Amazing to see how .NET 10 finally brings native Server-Sent Events support turning what used to be messy boilerplate into clean, intentional code. This is the kind of framework improvement that feels just right simple, powerful, and joyful to use. Kudos for breaking it down so clearly!