Challenging the Buffering Assumption

A common piece of advice in modern web development, particularly within .NET, is that streaming JSON arrays to the client is problematic. The prevailing wisdom suggests that JSON responses, especially arrays, are buffered by default. This means a client wouldn't see any data until the entire response is ready, effectively negating the benefits of streaming for long-running operations. The typical recommendation to overcome this is to use technologies like SignalR for real-time updates. However, a recent code review sparked a simple, yet revealing, experiment to test this assumption.

The scenario involved a progress endpoint for a lengthy import job. This endpoint was designed to return an IAsyncEnumerable<Step> from a .NET Minimal API. A colleague commented that the JSON response would buffer, and the client would receive nothing until the job completed. This sentiment—that streaming JSON arrays is inherently difficult or impossible without specialized libraries—is widespread. Developers often repeat this advice, myself included, without empirical verification. The experiment aimed to put a stopwatch to this widely held belief.

Minimal API endpoint returning IAsyncEnumerable for performance testing

The Test Rig Setup

To validate the assumption, a straightforward test environment was constructed. The core of the setup was a single .NET Minimal API endpoint. This endpoint was designed to simulate a long-running job by yielding a 'step' object every 400 milliseconds. The data structure for each step was intentionally kept minimal, typically containing a status message or an identifier for the progress stage. The goal was to see if the client would receive these steps incrementally as they were generated, or if it would wait for the entire sequence to be produced before receiving any data.

The API endpoint was mapped using app.MapGet("/steps/json", (CancellationToken ct) => ...). Inside the handler, an IAsyncEnumerable<Step> was returned. The implementation of this enumerable involved a loop that would yield a new Step object, `await Task.Delay(400, ct)`, and then repeat. This setup precisely mimics a common pattern for reporting progress on asynchronous, potentially lengthy operations. The question was whether the ASP.NET Core Kestrel server and the underlying JSON serializer would correctly handle the streaming of this asynchronous enumerable as a JSON array.

Observing Client-Side Behavior

The crucial part of the experiment was observing what the client actually received and when. Using a simple HTTP client, such as `HttpClient` in C# or even tools like `curl`, the request was made to the `/steps/json` endpoint. The expectation, based on the prevalent advice, was that the client would establish a connection, the server would begin generating steps, but the client would only receive a complete JSON array once the server finished iterating through all the steps. This would manifest as a long delay before the first byte of the response appeared, followed by the entire array being downloaded relatively quickly.

However, the results painted a different picture. When the endpoint was called, the client began receiving data almost immediately. Each yielded Step object appeared on the client side as a distinct element within the JSON array, separated by commas, and properly enclosed within the array's brackets. The server was effectively streaming the JSON array in chunks. As each step was generated and yielded by the IAsyncEnumerable, it was serialized and sent over the wire. This behavior directly contradicted the assumption that JSON arrays are always buffered entirely before being sent.

The stopwatch confirmed this. Instead of a single long delay, there were consistent, short delays between the arrival of each step. This indicated that the server was not waiting for the entire enumeration to complete. It was serializing and flushing each step as it became available. This streaming capability is a powerful feature, allowing for near real-time progress updates without the overhead of technologies like SignalR, which are typically reserved for bidirectional communication or more complex state management.

Why This Works: The Mechanics of Streaming

The underlying mechanism that enables this behavior lies within ASP.NET Core's handling of asynchronous enumerables and its integration with JSON serializers like System.Text.Json. When an IAsyncEnumerable<T> is returned from a Minimal API endpoint, ASP.NET Core's model binding and response formatting pipeline recognizes it. Instead of eagerly materializing the entire enumerable into a collection (like a List<T>) and then serializing that collection, the framework iterates over the IAsyncEnumerable.

For each item yielded by the enumerable, the serializer writes the corresponding JSON fragment. For an array, this means writing the opening bracket `[`, then the JSON for the first item, then a comma `,`, then the JSON for the second item, and so on. Crucially, the response stream is flushed periodically, sending these partial JSON structures to the client. This streaming continues until the enumerable is exhausted. At that point, the closing bracket `]` is written to the stream. This process ensures that the client receives data incrementally, making it appear as if the array is being built in real-time.

This is fundamentally different from how a synchronous collection would be handled. If the endpoint returned, for instance, a List<Step>, the entire list would need to be populated in memory first, then serialized into a complete JSON array string, and only then sent to the client. The IAsyncEnumerable, by its nature, allows for deferred execution and incremental production of data, which ASP.NET Core's pipeline is designed to leverage for efficient response generation.

Implications and When to Use SignalR

The discovery that Minimal APIs can stream JSON arrays directly challenges the need for more complex solutions like SignalR in many common progress-reporting scenarios. For developers building applications that involve long-running background tasks—such as data imports, report generation, or complex calculations—this built-in streaming capability offers a simpler and more performant alternative to buffering the entire response or implementing a full real-time communication framework.

This means that if your primary goal is to show the user progress updates for a server-side operation, and the updates can be represented as a sequence of objects, returning an IAsyncEnumerable<T> from your API endpoint is often sufficient. The client can then parse the incoming stream as a JSON array, updating its UI incrementally. This reduces the complexity of your backend architecture and the client-side code needed to manage the connection and data flow.

However, SignalR remains invaluable for scenarios requiring true real-time, bidirectional communication. If the server needs to push arbitrary messages to the client irrespective of an ongoing HTTP request, if clients need to send messages back to the server in real-time, or if complex state management and group messaging are required, SignalR is still the appropriate tool. The key takeaway is that for straightforward progress reporting via JSON arrays, the native streaming capabilities of ASP.NET Core Minimal APIs are now a viable and often preferable option.

Conclusion: A Subtle but Significant Shift

The experiment successfully debunked the myth that JSON arrays from .NET Minimal APIs are always buffered. The ability to stream IAsyncEnumerable<T> directly as a JSON array offers a powerful, built-in mechanism for handling long-running operations. This capability simplifies the development of responsive UIs that provide real-time feedback to users without adding unnecessary complexity. Developers should reconsider their approach to progress reporting and leverage these native streaming features where appropriate, reserving more complex real-time solutions for scenarios that truly demand them.