Streaming Rendering
Streaming rendering allows your server to send data to the client incrementally instead of buffering the entire response. This is particularly useful for long-running processes, progress updates, or keeping connections alive on platforms such as Cloudflare, which terminates idle HTTP connections after ~120 seconds if no data is sent.
This article explains how to implement streaming rendering in ASP.NET Core MVC using two approaches:
- AJAX - streaming using fetch().
- Server‑Sent Events (SSE) - both manually implemented and using .NET 10’s built‑in SSE support.
Regardless of which transport you use (AJAX or SSE), there are two common ways to produce streamed output on the server:
- Manually writing and flushing chunks to the response
- Publishing progress events
1. AJAX
When using AJAX to receive streamed chunks, the server needs to explicitly send partial data and flush the response buffer. You can stream data using either a loop that writes directly or by yielding chunks via IAsyncEnumerable.
Example 1 - Manual Writes and Flushes
[HttpPost("stream")]
[StreamRendering]
public async Task Stream(int timeout, CancellationToken cancellationToken) {
for (var i = 0; i < timeout; i++) {
await HttpContext.Response.WriteAsync($"Item {i + 1}");
await HttpContext.Response.Body.FlushAsync();
await Task.Delay(1000, cancellationToken);
}
}
Example 2 - Using IAsyncEnumerable to Yield Streamed Chunks
[HttpPost("stream")]
[StreamRendering]
public async Task Stream(int timeout, CancellationToken cancellationToken) {
async IAsyncEnumerable<string> GetChunks(int timeout, [EnumeratorCancellation] CancellationToken cancellationToken) {
for (var i = 0; i < timeout; i++) {
yield return $"Item {i + 1}";
await Task.Delay(1000, cancellationToken);
}
}
await foreach (var chunk in GetChunks(timeout, cancellationToken)) {
await HttpContext.Response.WriteAsync(chunk);
await HttpContext.Response.Body.FlushAsync();
}
}
Alternatively, instead of writing directly to HttpContext.Response, your action can publish progress updates via an event publisher. Each published event is streamed to the client as it occurs, achieving the same effect without coupling your business logic to HTTP response handling. For example:
await _eventPublisher.PublishAsync(new ProgressUpdateEvent("Processing..."));Client‑Side JavaScript
Streaming rendering works even without the client-side JavaScript shown below. In that case, the server-side implementation remains the same, and streaming still helps you avoid server timeout limits. However, without using JavaScript to handle the streamed chunks, you lose control over the user interface. The flushed output is simply written directly to the browser as it arrives, resulting in a plain, progressively updating page rather than a structured or styled UI.
Here's two examples of how you can control the UI using JavaScript:
Example 1 - Manually
const response = await fetch('/stream', {
method: 'POST',
body: new FormData(form)
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
console.log('Chunk:', chunk);
}
console.log('Stream finished!');Example 2 - Automatically
When you use the Ajax Form component, it automatically recognises streamed responses. This works because the StreamRendering attribute sets the X-Stream-Response header for you. You only need to add an element with the output class inside the form; streamed chunks will then be appended to the end of this element as they arrive.
2. Server‑Sent Events (SSE)
SSE provides a built‑in browser mechanism for streaming events over HTTP. The browser sends an "Accept: text/event-stream" header automatically when creating an EventSource.
Example 1 - Manual SSE Response
[HttpGet("sse")]
public async Task Sse(int timeout, CancellationToken cancellationToken) {
Response.ContentType = "text/event-stream";
for (var i = 0; i < timeout; i++) {
await HttpContext.Response.WriteAsync($"data: Item {i + 1}\n\n");
await HttpContext.Response.Body.FlushAsync();
await Task.Delay(1000, cancellationToken);
}
await HttpContext.Response.WriteAsync("event: done\ndata: end\n\n");
await HttpContext.Response.Body.FlushAsync();
}
Example 2 - Using .NET 10 Built‑In SSE
[HttpGet("sse")]
public async Task Sse(int timeout, CancellationToken cancellationToken) {
async IAsyncEnumerable<SseItem<string>> GetEvents([EnumeratorCancellation] CancellationToken cancellationToken) {
for (var i = 0; i < timeout; i++) {
yield return new SseItem<string>($"Item {i + 1}");
await Task.Delay(1000, cancellationToken);
}
yield return new SseItem<string>("end", "done");
}
await TypedResults.ServerSentEvents(GetEvents(cancellationToken)).ExecuteAsync(HttpContext);
}Client‑Side JavaScript
Here's the client-side code to call the endpoints above:
const es = new EventSource('/sse');
es.addEventListener('message', e => {
console.log('Message:', e.data);
});
es.addEventListener('done', e => {
console.log('Stream finished!');
es.close();
});
es.addEventListener('error', e => {
console.error('SSE error:', e);
});Technical Details
How Do You Do Server-Side Validation with Streaming Rendering?
To enable server-side validation, you must not write to the response stream until all validation has completed. Once the response is flushed, the HTTP headers are sent with a 200 OK status, making it impossible to return a BadRequest for validation errors.
If the action returns IActionResult, it must still return a result after streaming completes. Since the response has already been written and flushed, returning Ok() would attempt to write to the response again and cause an error. Instead, return new EmptyResult() to satisfy the action signature without modifying the already-committed response.
Why Do Streaming Rendering?
Cloudflare closes HTTP connections that remain idle for ~120 seconds. By streaming small chunks at intervals shorter than this threshold, you keep the connection alive.
Long‑running operations benefit from providing streaming feedback rather than a frozen UI.
How Does Streaming Rendering Work?
All streaming scenarios rely on two principles:
- The response body must not be buffered.
- Each chunk must be flushed so it reaches the client immediately.
In ASP.NET Core this usually works straight out the box. However, in KIT we use middleware to intercept the response and customize it, e.g. to support placeholders.
The KIT RenderHtmlMiddleware middleware has the following check at the top:
if (httpContext.Request.Headers.Accept.ToString().Contains("text/event-stream")
|| httpContext.GetEndpoint()?.Metadata.GetMetadata<StreamRenderingAttribute>() != null) {
await next(httpContext);
return;
}This causes SSE requests to bypass the HTML buffering/modification, since the "Accept: text/event-stream" header is automatically sent from the client. When using the client's fetch method this isn't the case. In this case you should manually add the StreamRendering attribute to your endpoint.
What Happens if Streaming is Not Detected in the Middleware?
If we do not detect streaming or SSE in the RenderHtmlMiddleware, the middleware will continue to execute the following:
var originBody = httpContext.Response.Body;
using var body = new MemoryStream();
httpContext.Response.Body = body;This replaces the response body stream with an in-memory buffer. All writes performed by downstream middleware are captured in this buffer instead of being sent directly to the client. We do this because the original response stream is write-only and cannot be rewound or modified. By buffering the response in memory, we can read and modify the complete output before finally writing the transformed content back to the real response stream.
