feat(http3): streamed response bodies, pushed rather than pulled - #173
Merged
Conversation
MDA2AV
force-pushed
the
feat/http3-streamed-response
branch
from
August 10, 2026 00:26
2d8cd15 to
18aa122
Compare
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
force-pushed
the
feat/http3-streamed-response
branch
from
August 10, 2026 00:32
18aa122 to
f575541
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ioxide.http3could only return a finishedHttp3Response, 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 throughHttp3Request.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-enterwritev, 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.Http3ResponseWriterIBufferWriter<byte>, so a serializer or a framework response sink writes into it unchanged. Pooled along with its buffer.RunStreamedResponseAsyncSubmitdoes.Backpressure is the connection's send retention:
FlushAsyncwaits whileCanQueueSendis false, so a producer cannot outrun a peer that has stopped reading.Numbers
8 × 1 KiB chunks, 2 reactors, 16 connections, 8 streams:
1.32×, and the simpler implementation.
Sample
Playground/Http3/ManagedStreamedshows both directions at once — uploads pulled a chunk at a time throughBodyReader, downloads pushed through the writer, and an endless/feedthat 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
/feedwhose client disappears mid-stream followed by 348,393 more requests served — the case that previously wedged the reactor in the nghttp3 path.