.NETAI AgentsASP.NET CoreStreaming

Streaming AI Agent Responses to a Web UI in .NET

Waiting for a full agent response feels broken. Learn how to stream tokens from a .NET agent to the browser with Server-Sent Events, so answers appear word-by-word.

Streaming AI Agent Responses to a Web UI in .NET

An agent that thinks for eight seconds and then dumps a wall of text feels broken, even when it’s working perfectly. The reason ChatGPT feels alive is streaming — words appear as they’re generated. If you’re building an agent-backed web UI in .NET, streaming isn’t a nice-to-have; it’s the difference between “is this thing frozen?” and a responsive product. This article wires a .NET agent’s streamed output to a browser using Server-Sent Events (SSE).

This builds on the agent from our tool-using agent tutorial and the API host from the Azure deployment guide.

Streaming at the agent level

The Microsoft Agent Framework exposes a streaming variant that yields partial updates as the model produces them, instead of returning one final string. Conceptually:

await foreach (var update in agent.RunStreamingAsync("Explain what a service mesh is."))
{
    Console.Write(update.Text);
}

Each update carries a Text property holding whatever part of the answer arrived in that chunk, and a Contents collection holding the raw content items behind it. That second property is the one people ignore and then trip over, because an update is not always text. The same enumeration also delivers FunctionCallContent when the model decides to invoke a tool and FunctionResultContent when the result comes back, plus reasoning text and usage data from providers that report them. Code that does output += update.Text and nothing else will render blank stretches whenever a non-text update goes past.

Granularity is not guaranteed either. How small each chunk is depends on the underlying chat client and, for hosted agent services, on what the service chooses to push. Some paths give you genuine token-level deltas; others give you whole messages at once, which streams correctly but looks like it arrives in one lump. Check what your provider actually emits before you build a typing animation around an assumption.

Why Server-Sent Events

For agent output you have three transport choices:

  • Server-Sent Events (SSE) — a one-way stream from server to browser over plain HTTP. Perfect for “server pushes tokens to client,” trivial to consume, and it survives proxies well.
  • WebSockets — bidirectional and heavier; overkill unless the client also needs to stream to the server mid-response, as with live audio.
  • SignalR — worth it if you’re already using it, or if you need server-initiated pushes outside a request, but more moving parts than a token stream needs.

SSE wins here because a chat turn is a request with a long answer, not a conversation between two peers. One HTTP request, one response body, ordinary auth cookies and headers, and no sticky-session requirement on your load balancer because the whole exchange lives inside a single request.

Where SSE stops being the right answer is worth naming. If the work outlives a browser tab — a research agent that runs for four minutes — do not stream it over a request the user might close. Start a job, return an id, and let the client poll or subscribe. If you need to send binary audio frames, or the client interrupts mid-generation with new input, that’s a WebSocket. And if the same answer must fan out to several viewers, you want a hub, not one stream per viewer re-running the model.

The SSE endpoint

In ASP.NET Core, set the text/event-stream content type and write each chunk in SSE’s data: format, flushing after every write so nothing buffers:

app.MapPost("/ask/stream", async (HttpContext ctx, AskRequest req, MyAgent agent) =>
{
    ctx.Response.Headers.ContentType = "text/event-stream";
    ctx.Response.Headers.CacheControl = "no-cache";
    ctx.Response.Headers["X-Accel-Buffering"] = "no";   // tell nginx not to buffer

    await foreach (var chunk in agent.RunStreamingAsync(
        req.Message, cancellationToken: ctx.RequestAborted))
    {
        if (string.IsNullOrEmpty(chunk.Text)) continue;
        await ctx.Response.WriteAsync($"data: {JsonSerializer.Serialize(chunk.Text)}\n\n");
        await ctx.Response.Body.FlushAsync();
    }

    await ctx.Response.WriteAsync("event: done\ndata: end\n\n");
    await ctx.Response.Body.FlushAsync();
});

record AskRequest(string Message);

Two details that matter in production. Pass ctx.RequestAborted into the streaming call: if the user closes the tab, generation stops early and you stop paying for tokens nobody will read. Flush after every chunk, because without FlushAsync the write can sit in a buffer and defeat the whole exercise. JSON-encoding each chunk avoids trouble when a fragment contains newlines, which would otherwise split one payload across two SSE frames.

On .NET 10 you can skip most of the framing. TypedResults.ServerSentEvents takes an IAsyncEnumerable<SseItem<T>> and handles the content type, the data: lines and the flushing for you, with SseItem<T> carrying the payload plus optional event type, event id and reconnection interval. Use it when your endpoint is a straight projection of an async sequence; keep the manual version when you need to interleave several kinds of frame with custom logic between them.

Why the naive version breaks in production

It works on localhost and then arrives in one block behind a proxy. The cause is almost always buffering somewhere you didn’t write. Response compression middleware is the usual suspect: gzip accumulates input before it emits a block, so a compressed SSE stream turns into a batched one — exclude text/event-stream from the compressed MIME types. nginx buffers proxied responses by default, which is what the X-Accel-Buffering: no header above turns off; other reverse proxies, CDNs and API gateways have their own equivalents, and some have none, which means they cannot be used in front of a stream at all. If the response body itself is being buffered by the hosting layer, IHttpResponseBodyFeature.DisableBuffering() is the switch. The diagnostic that saves you an afternoon: curl -N against the origin. If curl streams and the browser doesn’t, the problem is between them, not in your code.

The second production failure is the idle timeout. Agents pause — a tool call to a slow API can produce twenty seconds of silence — and load balancers and proxies close connections that go quiet. The fix is a heartbeat: a line starting with : is an SSE comment that clients ignore, so writing : ping\n\n every fifteen seconds keeps the connection alive without polluting the payload.

The third is the one that bites hardest, because it has no clean fix. Once you have written the first byte, the status code is already 200. If the model call throws on chunk forty — a rate limit, a content filter, a dropped upstream connection — you cannot return a 500, because the headers are long gone. You have exactly two options: emit an application-level error frame (event: error) that the client is written to handle, or close the connection abruptly and let the client treat truncation as failure. Pick the first, and make the client show the partial answer with an explicit failure marker rather than silently leaving half a sentence on screen looking like a finished response. This also breaks naive retry policies: a Polly retry cannot re-run a call whose output you already delivered. Retry only before the first byte; after that, surface the failure.

Cancellation has a matching sharp edge. When a stream terminates early through an exception or a triggered cancellation token, whatever state the agent was accumulating for that turn may not have been committed — this was a real reported defect in the Agent Framework, where the conversation thread was left un-updated after an early exit and the turn was lost. Treat a half-streamed turn as unknown, not as complete: persist what you delivered, mark it partial, and do not assume thread state matches what the user saw.

Consuming it in the browser

The client side is refreshingly small. Because this endpoint is a POST, use fetch with a streaming reader rather than EventSource, which only issues GETs:

const res = await fetch('/ask/stream', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message: input.value }),
});

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });

  for (const line of buffer.split('\n\n')) {
    if (line.startsWith('data: ')) {
      output.textContent += JSON.parse(line.slice(6));
    }
  }
  buffer = buffer.endsWith('\n\n') ? '' : buffer.split('\n\n').pop();
}

The trade-off is that you give up EventSource’s automatic reconnection and its Last-Event-ID replay, so reconnection is yours to implement or to decline. Declining is a defensible choice for chat: resuming a half-generated answer usually means re-running the turn anyway.

Note output.textContent. Model output is untrusted input, and the moment you swap that for innerHTML to render markdown, you have an XSS hole fed by whatever a user can talk the model into echoing — including content the model read from a tool result. Render through a markdown library with sanitisation on. Streaming makes this fiddlier than it sounds, because at any instant you may be holding a half-open code fence or an unterminated link; render incrementally with a tolerant parser, or hold formatting until the frame completes and show plain text in the meantime.

Streaming with tool calls

When an agent calls a tool mid-response there’s a pause while the tool runs, and the stream emits structured events rather than text. Surface them. A subtle “Looking up your order…” indicator keeps the pause from reading as a hang, and it costs you one branch in the loop: inspect the content type on each update and write a distinct SSE event: for tool activity so the client can switch state. This is also the moment your heartbeat matters most, because a slow tool is exactly the silence a proxy will kill.

The number worth measuring is time to first token, not total duration. Streaming barely changes how long a turn takes end to end — it may add a little, since you are flushing many small writes instead of one large one — but it collapses perceived latency, and users judge on the first word. If your TTFT is poor, streaming will not rescue it; that is a latency budget problem, and a long system prompt or a retrieval step in front of the model is the usual cause.

Note: the streaming method name and the exact update type have moved across Agent Framework releases, though Text and Contents have been stable. The pattern — async-enumerate updates, relay each over SSE, flush, honour cancellation — is the durable part; confirm the current API against the official docs.

Takeaway

Streaming turns a .NET agent from something that feels frozen into something that feels fast, and Server-Sent Events give you that with almost no infrastructure: an await foreach over the agent’s updates, each chunk written as an SSE frame and flushed, and a small fetch reader on the client. The work that separates a demo from a product is everything around it — disabling buffering in the proxy and the compression middleware, heartbeating through tool-call silences, handling failures that arrive after the 200, treating an aborted stream as an incomplete turn rather than a finished one, and sanitising output before it reaches the DOM. Get those right and the word-by-word answer is the easy part.

Next: building an AI chatbot in Blazor for the full UI around this.


Have a correction or a topic you want covered? Email mani.bc72@gmail.com.