Skip to content

feat(http3): streamed response bodies, pushed rather than pulled - #173

Merged
MDA2AV merged 4 commits into
mainfrom
feat/http3-streamed-response
Aug 10, 2026
Merged

feat(http3): streamed response bodies, pushed rather than pulled#173
MDA2AV merged 4 commits into
mainfrom
feat/http3-streamed-response

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Aug 9, 2026

Copy link
Copy Markdown
Owner

ioxide.http3 could only return a finished Http3Response, so a large body cost its own size in memory and an endless one could not be expressed at all. The request direction already streamed through Http3Request.BodyReader — this is the other half.

Why this one is simple

Owning the framing means a chunk is just [0x00][varint length][payload] handed to the QUIC stream. No data-reader callback to answer, nothing to defer out of a library call, no buffer whose lifetime something else dictates.

Compare the nghttp3 version (#172): nghttp3 pulls, which needs WOULDBLOCK, resume_stream, a deferred resume so a pull does not re-enter writev, and native buffers because it holds pointers into them across the callback. None of that exists here — QUIC streams are independent, so nothing has to be interleaved either.

Http3ResponseWriter IBufferWriter<byte>, so a serializer or a framework response sink writes into it unchanged. Pooled along with its buffer.
RunStreamedResponseAsync Dispatches through its own path, inside the pass that made the request ready — so a writer's sends flush with that pass exactly as a buffered Submit does.

Backpressure is the connection's send retention: FlushAsync waits while CanQueueSend is false, so a producer cannot outrun a peer that has stopped reading.

Numbers

8 × 1 KiB chunks, 2 reactors, 16 connections, 8 streams:

req/s MB/s
nghttp3 streamed 92,689 724
pure C# streamed 122,083 954

1.32×, and the simpler implementation.

Sample

Playground/Http3/ManagedStreamed shows both directions at once — uploads pulled a chunk at a time through BodyReader, downloads pushed through the writer, and an endless /feed that buffering cannot express at all.

Verification

Unit 29, E2E 46, Http 35, Tls 15, File 4, Chaos 37 pass. 990,134 requests with zero failures, plus an endless /feed whose client disappears mid-stream followed by 348,393 more requests served — the case that previously wedged the reactor in the nghttp3 path.

@MDA2AV
MDA2AV force-pushed the feat/http3-streamed-response branch from 2d8cd15 to 18aa122 Compare August 10, 2026 00:26
MDA2AV added 4 commits August 10, 2026 01:30
ioxide.http3 could only return a finished Http3Response, so a large body cost
its own size in memory and an endless one could not be expressed at all. The
request direction already streamed through Http3Request.BodyReader; this is
the other half.

Owning the framing is what makes it simple. A chunk is just
[0x00][varint length][payload] handed to the QUIC stream, so there is no
data-reader callback to answer, nothing to defer out of a library call, and no
buffer whose lifetime something else dictates. Compare the nghttp3 version:
nghttp3 pulls, which needs WOULDBLOCK, resume_stream, a deferred resume to
avoid re-entering writev, and native buffers it holds pointers into.

  Http3ResponseWriter    IBufferWriter<byte>, so a serializer or a response
                         sink writes into it unchanged. Pooled with its buffer.
  RunStreamedResponseAsync   dispatches through its own path, inside the pass
                         that made the request ready, so a writer's sends flush
                         with that pass exactly as a buffered Submit does.

Backpressure is the connection's send retention: FlushAsync waits while
CanQueueSend is false, so a producer cannot outrun a peer that stopped reading.

  8 x 1KiB, 2 reactors, 16 conns, 8 streams
    nghttp3 streamed     92689 req/s   724 MB/s
    pure C# streamed    122083 req/s   954 MB/s     1.32x

Playground/Http3/ManagedStreamed shows both directions at once: uploads pulled
a chunk at a time through BodyReader, downloads pushed through the writer, and
an endless /feed that buffering cannot express.

Unit 29, E2E 46, Http 35, Tls 15, File 4, Chaos 37 pass. 990134 requests with
zero failures, and an endless /feed whose client disappears mid-stream followed
by 348393 more requests served.
The sample, its baseline and its pane land together, rather than the sample
arriving now and the other two being noticed later - which is the state
Http3/Managed and Http3/Streamed were in until an hour ago.

The pane's footer deliberately does not link the nghttp3 streamed tab: that
tab comes from a different open PR, and a cross-PR label would be a dead link
in whichever merges first.

  Http3/ManagedStreamed   28906 req/s   68.95us   (new baseline)

That cell is a 64 x 16 KiB response - a megabyte per request, so the low
req/s is the body size, not a regression. Registered with the same 8 x 1 KiB
chunking the nghttp3 streamed cell uses.
The sample showed each direction separately - /upload pulled a request body,
/ pushed a response - but nothing exercised them together, which is the shape
that actually matters: a proxy reads a chunk and writes a chunk, holding one
at a time.

/echo does that. Neither side can outrun the other, because ReadAsync waits on
the peer and FlushAsync waits on the connection having room.

Exercised with h3x --requests, which can carry a request body:

  38-byte body     50 requests,   0 failed
  1 MiB body     4330 requests,   0 failed, peak RSS 99 MB

That RSS is the result worth reading. 4330 requests carrying a megabyte IN and
a megabyte OUT, four concurrent, and the process never grew past 99 MB -
buffering either direction would have shown gigabytes. The memory being flat
is what says both halves stream.

Still NOT verified byte-for-byte: h3x does not validate response payloads and
the curl here is built without HTTP/3, so an echo that corrupted its payload
would pass this. That wants a test using ioxide's own h3 client, which is the
right home for the assertion anyway.
Resolving the docs conflict by unioning both sides was wrong for HTML: it left
the new pane div nested inside the previous pane's head, which the generator
then silently overwrote while rewriting its neighbour. Taking main's file and
reapplying the three additions - tab, label, CSS - produces the same result
without the interleaving.

Verified: 41 samples, 41 registered, 41 generated; no duplicate tabs or panes;
tabs, labels, panes and CSS agree; generators idempotent; the pane compiles
standalone.
@MDA2AV
MDA2AV force-pushed the feat/http3-streamed-response branch from 18aa122 to f575541 Compare August 10, 2026 00:32
@MDA2AV
MDA2AV merged commit bf7375e into main Aug 10, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant