DEV Community

Sukhpinder Singh
Sukhpinder Singh

Posted on

Your JSON Array Was Streaming All Along

I set out to prove a code-review comment right. The stopwatch had other plans.

Code review last week: a progress endpoint for a long import job, returning IAsyncEnumerable<Step> from a minimal API. The comment under it said what I'd have written myself a month ago: "JSON responses buffer, the client won't see anything until the job finishes. Use SignalR." I've repeated that advice for years without once pointing a stopwatch at it. So before approving, I did.

The rig

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

app.MapGet("/steps/json",(CancellationTokenct)=>Produce(padding:0,ct));asyncIAsyncEnumerable<Step>Produce(intpadding,[EnumeratorCancellation]CancellationTokenct=default){for(vari=1;i<=TotalSteps;i++){awaitTask.Delay(DelayMs,ct);yieldreturnnewStep(i,$"step {i}/{TotalSteps}",padding==0?"":newstring('x',padding));}}
Enter fullscreen modeExit fullscreen mode

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, since I'm about to quote numbers: .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.

If the folklore holds, the log should show silence for three seconds and then one fat read.

The folklore loses

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 3 1465 ms 40 B ,{"number":3,"name":"step 3/8","pad":""}
read 4 1866 ms 40 B ,{"number":4,"name":"step 4/8","pad":""}
read 5 2266 ms 40 B ,{"number":5,"name":"step 5/8","pad":""}
read 6 2667 ms 40 B ,{"number":6,"name":"step 6/8","pad":""}
read 7 3068 ms 40 B ,{"number":7,"name":"step 7/8","pad":""}
read 8 3469 ms 41 B ,{"number":8,"name":"step 8/8","pad":""}]
done 3471 ms 321 B in 8 read(s)
Enter fullscreen modeExit fullscreen mode

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.

So the new SSE support is pointless?

That was my second wrong take of the afternoon. The server was never the problem; the consumer is. What arrives is a JSON array with its closing ] missing until the very end. If the caller is another .NET service, that's fine, because the deserializer streams too:

awaitforeach(varstepinJsonSerializer.DeserializeAsyncEnumerable<Step>(stream,JsonSerializerOptions.Web))Console.WriteLine($"item {step!.Number}{sw.ElapsedMilliseconds} ms");
Enter fullscreen modeExit fullscreen mode
item 1 413 ms
item 2 803 ms
item 3 1204 ms ...usable as they arrive, same endpoint, no protocol change
Enter fullscreen modeExit fullscreen mode

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, which is 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",(CancellationTokenct)=>TypedResults.ServerSentEvents(ProduceSse(ct)));asyncIAsyncEnumerable<SseItem<Step>>ProduceSse([EnumeratorCancellation]CancellationTokenct=default){awaitforeach(varstepinProduce(padding:0,ct))yieldreturnnewSseItem<Step>(step,eventType:"step"){EventId=step.Number.ToString()};}
Enter fullscreen modeExit fullscreen mode

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

Enter fullscreen modeExit fullscreen mode

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

constsource=newEventSource("/steps/sse");source.addEventListener("step",e=>render(JSON.parse(e.data)));
Enter fullscreen modeExit fullscreen mode

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. That's SignalR's turf and 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. And 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, stated as such: 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

Top comments (3)

Collapse
 
nazar-boyko profile image
Nazar Boyko

Didn't expect the response headers to hold until the first yield, that one has a sharp edge for slow producers. If the job does a few seconds of setup before the first item, you send zero bytes that whole time, and a proxy or load balancer with a short header timeout can cut the connection before item one lands. Cheap workaround is to yield a "started" element right away so the headers flush. Did you run into that once the producer got slow?

Collapse
 
wrencalloway profile image
Wren Calloway

The buffering-middleware caveat at the end is the whole ballgame for anyone deploying this, and it deserves more than a footnote. The nastiest version isn't compression — it's that your streaming works perfectly in the same-container localhost rig and then dies the moment a proxy sits in front. nginx buffers proxied responses by default (proxy_buffering on), so your beautifully flushed 40-byte reads get coalesced and delivered as one blob at the end — reproducing the exact folklore symptom you just disproved, one layer up. SSE has a semi-standard escape hatch (X-Accel-Buffering: no), but your plain-array streaming path has no such signal, so a proxy can't tell it apart from a normal JSON response it's free to buffer.

That's an underrated argument for SSE that's separate from the browser story: text/event-stream is a recognized "don't buffer me" hint across a lot of infra, whereas a half-open application/json array is indistinguishable from a buffer-me-please response. The protocol isn't just carrying reconnect bookkeeping — it's carrying intent that the intermediaries actually respect.

Collapse
 
unitbuilds profile image
UnitBuilds

This details honestly my biggest gripe with .NET. Everything 'looks' efficient, but when you dig, you see it's actually stupid. They could have built it as fill-in-the-middle, so the first payload has the closing brace and each call just appends the pointer end coordinates and 'fills in' the missing values inside the array. Same payload size, but immediate response, not 3 seconds later for a closing brace. Simple things, that come back to why I love Rust, zero-allocation. If they really want to make .NET better, they really need to start looking into optimizing it the way Rust code is optimized. I mean look at C# backend and Blazor frontend. Did you know that Snackbars have a bug? If a Snackbar pops up, it's never actually disposed of properly, so it blocks the UI where it used to be even after it's closed... Or how about the VDOM bloat, that nobody seems to care about, yet it's why a page takes 22 seconds to load, instead of 0.2? I build V.A.L.I.D. as a way to kill off CSLA, but ended up fixing that exact problem, by using a bit of Rust logic, with unmanaged slabs as a replacement for the VDOM and result is a 6x performance gain, even with multi-parameter rule executions... I dont understand how Microsoft wants to compete, yet they cant build a framework that actually works well enough to compete.