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
EventSourcethat reconnects on its own, sends theLast-Event-IDheader "to the server when the user agent is to reestablish the connection", and honours the server'sretryfield.
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
EventSourcecannot carry one. Today that is oneusingand oneawait foreach. - A relay that is nothing but a pipe. That is a feature, and it is tested as one.
- An
Acceptheader 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.
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.