DEV Community

Your JSON Array Was Streaming All Along

The rig

One minimal API, one fake job that yields a step every 400 ms:

app.MapGet("/steps/json", (CancellationToken ct) => Produce(padding: 0, ct));

async IAsyncEnumerable<Step> Produce(int padding, [EnumeratorCancellation] CancellationToken ct = default)
{
    for (var i = 1; i <= TotalSteps; i++)
    {
        await Task.Delay(DelayMs, ct);
        yield return new Step(i, $"step {i} / {TotalSteps}", padding == 0 ? "" : new string('x', padding));
    }
}

The probe is deliberately dumb: HttpCompletionOption.ResponseHeadersRead, then raw stream.ReadAsync in a loop, logging elapsed time and byte count for every read. No JSON parsing, no framework help on the client side. I only want to know when bytes hit the wire.

Conditions: .NET 10 (SDK 10.0.302), Kestrel and the client in the same Linux container, localhost. Not a lab. I care about the arrival pattern, not the milliseconds.

The folklore loses

GET /steps/json headers 675 ms 200 application/json
read1 680 ms 40 B [{"number":1,"name":"step 1/8","pad":""}
read2 1066 ms 40 B ,{"number":2,"name":"step 2/8","pad":""}
read3 1465 ms 40 B ,{"number":3,"name":"step 3/8","pad":""}
read4 1866 ms 40 B ,{"number":4,"name":"step 4/8","pad":""}
read5 2266 ms 40 B ,{"number":5,"name":"step 5/8","pad":""}
read6 2667 ms 40 B ,{"number":6,"name":"step 6/8","pad":""}
read7 3068 ms 40 B ,{"number":7,"name":"step 7/8","pad":""}
read8 3469 ms 41 B ,{"number":8,"name":"step 8/8","pad":""}]
done 3471 ms 321 B in 8 read(s)

Eight reads. Forty bytes each. One every 400 ms, landing the moment each element was yielded. The array was streaming the whole time: opening bracket first, elements as they came, closing bracket three seconds later.

I assumed tiny payloads were a fluke, so I re-ran it with ~4 KB per element. Same rhythm, ~4.1 KB per tick. System.Text.Json's async path flushes pending output when your producer goes off to await something, and minimal APIs have been quietly good at this for a while now.

The warning I kept repeating does have an ancestor, to be fair: MVC's Newtonsoft.Json path really does buffer IAsyncEnumerable to the end. I didn't retest that path here. But on minimal APIs with System.Text.Json, on current .NET, it's simply not your problem.

One small detail from the logs I hadn't thought about: the response headers didn't leave until the first item did, in every variant. Your TTFB is your first yield, not your return.

A browser is a different story

fetch(...).json() resolves when the body ends, so the dashboard renders nothing for the whole job and then everything at once - exactly the symptom that convinced all of us the server was buffering. You could hand-roll an incremental parser over a half-open array. Nobody does. They install SignalR, for a one-way progress feed.

.NET 10 finally hands that job to the right tool. Same producer, one different return type:

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

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

curl -N shows classic text/event-stream framing, one event per yield, same 400 ms heartbeat:

event: step
data: {"number":1,"name":"step 1/8","pad":""}
id: 1

event: step
data: {"number":2,"name":"step 2/8","pad":""}
id: 2

And the browser side is two lines, no package, no hub, no negotiation handshake:

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

EventSource reconnects on its own and sends a Last-Event-Id header when it does - that's why I bothered setting EventId. Resuming from that header is still your code to write, but the protocol carries the bookkeeping for free.

Where I landed

The framing isn't free: 520 B for eight events versus 321 B for the plain array. Sixty-ish percent overhead on comically small payloads, rounding error on real ones.

Where I wouldn't use SSE:

  • Service-to-service calls, since the plain array plus DeserializeAsyncEnumerable is already streaming
  • Anything needing client-to-server messages on the same channel
  • Fan-out to huge audiences where you want groups and a backplane (SignalR's turf - it earns it there)

Two more things worth knowing:

  • On HTTP/1.1 browsers allow roughly six connections per origin and every open EventSource holds one, so serve this over HTTP/2.
  • Buffering middleware - response compression, some reverse proxies - can still flatten either approach into one blob at the end. The folklore isn't dead; it just moved up a layer.

My take

For one-way progress and dashboard feeds on .NET 10, SSE should be the default and SignalR the exception you argue for. A return statement beat a hub for this endpoint.

The actual lesson cost me an afternoon: the advice I nearly left in that review was years stale. Measure the folklore once in a while. It goes off.

Full runnable sample: https://github.com/ssukhpinder/dev-to-code-samples/tree/main/004-json-streaming-vs-sse

What's a piece of .NET folklore you've caught being stale? Tell me in the comments and I'll point the stopwatch at it.

  • Sukhpinder, still pointing stopwatches at endpoints nobody complained about

Comments

No comments yet. Start the discussion.