The Myth: JSON Responses Buffer Until Completion

A common piece of .NET folklore states that returning IAsyncEnumerable from a minimal API results in the entire JSON array being buffered on the server before sending. Developers often advise using SignalR for streaming scenarios. Sukhpinder set out to prove that advice right — but the stopwatch revealed otherwise.

The Benchmark: 400ms Yields, 40B Reads

Sukhpinder created a minimal API endpoint that yields a Step object every 400ms. The client used raw HttpCompletionOption.ResponseHeadersRead and stream.ReadAsync to log every byte arrival. The test ran on .NET 10 (SDK 10.0.302), Kestrel and client in the same Linux container, localhost.

Results for GET /steps/json:

headers      675 ms   200 application/json
read  1      680 ms       40 B   [{"number":1,"name":"step 1/8","pad":""}
read  2     1066 ms       40 B   ,{"number":2,"name":"step 2/8","pad":""}
...
read  8     3469 ms       41 B   ,{"number":8,"name":"step 8/8","pad":""}]
done        3471 ms   321 B in 8 read(s)

Eight reads, each ~40 bytes, arriving exactly every 400ms — the moment each element was yielded. The array was streaming: opening bracket first, elements as they came, closing bracket three seconds later. Even with ~4KB per element, the same rhythm held (~4.1KB per tick).

Why it works: System.Text.Json's async path flushes pending output when the producer awaits something. Minimal APIs with System.Text.Json on current .NET do not buffer. The warning originated from MVC's Newtonsoft.Json path, which does buffer to the end.

The Real Bottleneck: Browser's fetch().json()

While the server streams, the browser's fetch(...).json() waits for the entire body to complete before resolving. That's why dashboards appear to freeze — not because the server buffers, but because the client waits for the closing ].

Service-to-service calls work fine:

await foreach (var step in JsonSerializer.DeserializeAsyncEnumerable(stream, JsonSerializerOptions.Web))
    Console.WriteLine($"item {step!.Number}   {sw.ElapsedMilliseconds} ms");

Items arrive as they are yielded. No protocol change needed.

.NET 10 SSE: The Browser-Friendly Solution

.NET 10 introduces TypedResults.ServerSentEvents for minimal APIs. Same producer, different return type:

app.MapGet("/steps/sse", (CancellationToken ct) =>
    TypedResults.ServerSentEvents(ProduceSse(ct)));

async IAsyncEnumerable> ProduceSse([EnumeratorCancellation] CancellationToken ct = default)
{
    await foreach (var step in Produce(padding: 0, ct))
        yield return new SseItem(step, eventType: "step") { EventId = step.Number.ToString() };
}

Client-side, two lines:

const source = new EventSource("/steps/sse");
source.addEventListener("step", e => render(JSON.parse(e.data)));

EventSource automatically reconnects and sends Last-Event-Id headers. The server must handle resumption, but the protocol carries bookkeeping for free.

Overhead and Caveats

SSE framing adds overhead: 520 bytes for eight events vs 321 bytes for the plain array (~60% overhead on small payloads, negligible on real ones).

Where not to use SSE:

  • Service-to-service calls (plain array + DeserializeAsyncEnumerable already streams)
  • Bidirectional communication (use SignalR)
  • Large fan-out with groups and backplane (SignalR's turf)

Important considerations:

  • HTTP/1.1 browsers allow ~6 connections per origin; every open EventSource holds one. Serve over HTTP/2.
  • Buffering middleware (response compression, reverse proxies) can still flatten either approach. The folklore just moved up a layer.

Conclusion: Measure the Folklore

Sukhpinder's take: for one-way progress and dashboard feeds on .NET 10, SSE should be the default and SignalR the exception. The advice he nearly left was years stale.

Full runnable sample: GitHub repo

What's a piece of .NET folklore you've caught being stale?