Skip to main content
Dejan Demonjić brand logoDejan Demonjić
Decision log
Work
About
Contact

Decision log / Decision record

Server-Sent Events in ASP.NET Core 10, and when to pick them over SignalR

When a server talks and a client listens for the length of one request, Server-Sent Events is the right transport: plain HTTP, one direction, a text protocol a proxy carries and a browser reconnects by itself. .NET 10 made it a first-class result. The parts that bite are on the client: the protocol has rules, and some servers decide per response whether to answer with JSON or with a stream.

Filed
2026-10-16
Topic
Shipping products
Reading
7 min
Tags
#sse#aspnetcore#signalr#mcp#realtime

Part 1 of 2 Realtime transports

  1. Server-Sent Events in ASP.NET Core 10, and when to pick them over SignalR
  2. SignalR between a cloud and its sites
Next SignalR between a cloud and its sites

The claim

When the server talks and the client listens for the length of one request, Server-Sent Events is the transport to reach for. It is plain HTTP in one direction, a text protocol any proxy can carry, and browsers reconnect to it by themselves. ASP.NET Core 10 made it a first-class result type. What bites is not the server. It is the client, where a line-based protocol has rules people skip, and where some servers decide per response whether to answer with a JSON document or with a stream. This note is what .NET 10 gives you, how an assistant's answers stream through Hexavera, and the four rules a client has to follow.

What .NET 10 added

ASP.NET Core 10 "supports returning a ServerSentEvents result using the TypedResults.ServerSentEvents API", in Minimal APIs and in controllers. Each message is an SseItem<T>, "which may contain an event type, an ID, and a data payload of type T", plus a reconnection interval that becomes the stream's retry: field. The method takes an IAsyncEnumerable<SseItem<T>>, or an IAsyncEnumerable<T> with an optional event type.

app.MapGet("/answers/{id}", (Guid id, IAnswerStream answers, CancellationToken cancellationToken) =>
    TypedResults.ServerSentEvents(Chunks(answers, id, cancellationToken)));

static async IAsyncEnumerable<SseItem<AnswerChunk>> Chunks(
    IAnswerStream answers, Guid id, [EnumeratorCancellation] CancellationToken cancellationToken)
{
    await foreach (var chunk in answers.ReadAllAsync(id, cancellationToken))
    {
        yield return new SseItem<AnswerChunk>(chunk, eventType: "chunk") { EventId = chunk.Id };
    }
}

Reading the result's implementation in the 10.0 branch tells you what you no longer have to remember. It sets Content-Type: text/event-stream, Cache-Control: no-cache,no-store, Pragma: no-cache and Content-Encoding: identity, and it disables response buffering, so a chunk leaves when you yield it. A string item is written as it is; a byte[] as it is; anything else is serialized with the application's JsonOptions. It also adds two pieces of endpoint metadata: the response type (text/event-stream) for OpenAPI, and the marker that stops cookie authentication from answering an unauthenticated stream request with a redirect to a login page instead of a 401.

The protocol pieces live in System.Net.ServerSentEvents, which also ships as a package down to .NET Standard 2.0: SseFormatter writes a stream, and SseParser.Create(stream) reads one, with EnumerateAsync yielding each item, and LastEventId and ReconnectionInterval tracked the way the specification says. Use it on the client; the rest of this note is why.

How an assistant's answer streams

Vera, Hexavera's assistant, streams every answer over text/event-stream: chunks of text as plain data: lines, and named events for the session the answer belongs to, metadata, citations, tool activity, usage, done and error. The chat endpoint sets Content-Type: text/event-stream, Cache-Control: no-cache and Connection: keep-alive itself and writes each frame by hand. The desktop and Blazor clients send the question with an HttpClient, read with HttpCompletionOption.ResponseHeadersRead, and consume the body as it arrives. A site runtime relays the stream for the clients behind it: a controller that sets the same three headers and copies the request body up and the response body down, without parsing a line. That relay is the strongest argument for SSE on this path. It is HTTP; it pipes.

Why not the browser's EventSource? Its whole constructor is new EventSource(url, { withCredentials }). There is no way to set a request header, so a bearer token cannot travel with it. A client that authenticates with a token streams through fetch or HttpClient instead, and parses the events itself. Which is where the rules start.

Four rules a client has to follow

All four came from the assistant's MCP client, which talks to tool servers over the MCP streamable-HTTP transport.

1. Say what you accept. The MCP specification: "The client MUST include an Accept header, listing both application/json and text/event-stream as supported content types." The client sent no Accept at all until 2026-08-21 and was refused by every server it ever asked. Measured on a live stack: without it, 406; with it, a result.

2. The server may choose per response. For a JSON-RPC request, "the server MUST either return Content-Type: text/event-stream, to initiate an SSE stream, or Content-Type: application/json, to return one JSON object. The client MUST support both these cases." Hexavera's own servers answer initialize as a single event:

event: message
data: {"result":{...},"id":"initialize","jsonrpc":"2.0"}

A client that assumes JSON gets a parse exception and reports "invalid response" against a server that answered exactly as the specification allows. That is where the assistant's discovery of its tool servers sat, once two other defects in front of it had been cleared.

3. Data fields accumulate. The specification is precise: for a data field, "Append the field value to the data buffer, then append a single U+000A LINE FEED (LF) character to the data buffer", and on dispatch, "If the data buffer's last character is a U+000A LINE FEED (LF) character, then remove the last character from the data buffer." A long or pretty-printed JSON-RPC body arrives as several data: lines of one event. A reader that keeps only the last line yields a fragment that fails to parse, again against a server that answered correctly.

4. Line endings, final events, and which event wins. CRLF and bare LF both arrive, and neither the server nor an intermediary promises which. The specification discards a final event that the stream closes on before its blank line, so a server must end every event with one; a reader that keeps such an event is forgiving a server defect. And a result may follow progress notifications, so for a request-response exchange the last event's data is the answer.

SseParser takes care of the third rule and of the line endings in the fourth, and it discards an unterminated final event as the specification requires; the Accept header, the content-type switch and keeping the last event stay in your code, the last two as below:

var mediaType = response.Content.Headers.ContentType?.MediaType;
if (!string.Equals(mediaType, "text/event-stream", StringComparison.OrdinalIgnoreCase))
{
    return await response.Content.ReadAsStringAsync(cancellationToken);     // the server chose JSON
}

string? last = null;
var parser = SseParser.Create(await response.Content.ReadAsStreamAsync(cancellationToken));
await foreach (var item in parser.EnumerateAsync(cancellationToken))
{
    last = item.Data;                                                        // the result follows any progress events
}

return last ?? throw new InvalidOperationException("The event stream carried no data.");

The chat client in Hexavera still reads lines by hand. It works because the server is ours and writes one line per field. For a new client, that loop is not worth writing again.

When SSE, and when SignalR

SignalR is the other answer in this stack, and the next note is about it. The facts that decide between them:

  • SignalR gives you a hub, "a high-level pipeline that a client and server use to call methods on each other", connection management, messages "to specific clients or groups of clients", and transport negotiation across WebSockets, Server-Sent Events and long polling. It uses SSE itself as one of its transports.
  • SSE gives you one direction over one HTTP response, and in a browser an EventSource that reconnects on its own, sends the Last-Event-ID header "to the server when the user agent is to reestablish the connection", and honours the server's retry field.

The rule: when the stream is the answer to one request, and the client has nothing to say until it ends, use SSE. An assistant's reply, the progress of one export, one viewer's live status. When many clients must be addressed by tenant or by user, when the client talks back on the same connection, or when the server has to know who is connected right now, use SignalR, and accept the connection lifecycle that comes with it.

What it cost

  • A protocol parser on every client that authenticates with a token, because EventSource cannot carry one. Today that is one using and one await foreach.
  • A relay that is nothing but a pipe. That is a feature, and it is tested as one.
  • An Accept header and a content-type switch in every MCP call, and a test for each shape the server may answer with.

What a team can take from this

Return TypedResults.ServerSentEvents and let the framework own the headers and the buffering. On the client, read the response's content type before you read its body, send Accept for both shapes, and parse the stream with SseParser rather than with ReadLineAsync. When the server is yours, the hand-rolled reader will work right up until the day someone pretty-prints a payload.

DD

Dejan Demonjić

Principal software engineer, Belgrade

Designs and builds secure, multi-tenant .NET platforms. Questions about this entry, or a different view on it, are welcome by email.

  • Email
  • LinkedIn
  • GitHub
Previous The tenant comes from the token, not from the headerIn a multi-tenant API the tenant a request acts in is decided from who is calling. A header may name one; the token says whether the caller may act there. The decision record: one middleware owns the header, the status code follows the caller, and authority that arrives through a replicated directory is trusted for one token lifetime.Next Mutual TLS and certificate-bound tokens for the links that matterClient credentials alone let a token travel. For the links between a site and its cloud, and between an assistant's tool servers and the API, every token is bound to the client's certificate (RFC 8705), tokens are exchanged instead of forwarded (RFC 8693), and resources announce their issuer (RFC 9728). The decision record, with the parts that only building it revealed.

In this entry

  1. The claim
  2. What .NET 10 added
  3. How an assistant's answer streams
  4. Four rules a client has to follow
  5. When SSE, and when SignalR
  6. What it cost
  7. What a team can take from this
Dejan Demonjić

Designing and building secure, multi-tenant .NET platforms, and writing down the decisions behind them.

Read

  • Decision log
  • Work
  • RSS feed

Contact

  • dejan@demonjic.com
  • Write to me
  • About

Elsewhere

© Dejan Demonjić. Written in Belgrade.