The response body produced OVER TIME instead of handed over whole - each flush becomes a DATA frame. That is what /feed demonstrates: an endless response has no final byte, so a buffered API cannot express it at all. Nghttp3ResponseWriter is an IBufferWriter<byte>, so a serializer or a framework's response sink writes into it unchanged, and FlushAsync returning only once nghttp3 has taken the chunk is what stops a producer outrunning a peer that has stopped reading. nghttp3 PULLS body bytes rather than accepting pushes, which is why this carries a resume and a drain the pure-C# writer does not need.
// dotnet add package ioxide
+// dotnet add package ioxide.ngtcp2
+// dotnet add package ioxide.http3
+// curl --http3-only -k https://127.0.0.1:8443/
+// curl --http3-only -kN https://127.0.0.1:8443/feed # never ends
+// curl --http3-only -k --data-binary @big.bin https://127.0.0.1:8443/echo
+
+using System.Text;
+using ioxide;
+using ioxide.http3;
+using ioxide.ngtcp2;
+
+// ── Knobs ────────────────────────────────────────────────────────────────────────────────────
+
+ushort quicPort = 8443;
+int reactors = Environment.ProcessorCount;
+
+
+// Chunks written per response on "/", and the size of each. Their product is never held at once.
+int chunkCount = 64;
+int chunkBytes = 16 * 1024;
+
+
+int udpRecvSlots = 16;
+
+string? certOverride = null;
+string? keyOverride = null;
+// ─────────────────────────────────────────────────────────────────────────────────────────────
+
+const string certPath = "cert.pem"; // any PEM pair
+const string keyPath = "key.pem";
+
+using var engine = new QuicEngine(certPath, keyPath, cidLength: 8, alpn: ["h3"]);
+
+var config = new ServerConfig
+{
+ ReactorCount = reactors,
+ Tcp = null,
+ Udp = new UdpOptions { RecvSlots = udpRecvSlots },
+ Quic = new QuicOptions
+ {
+ Port = quicPort,
+ LocalCidLength = 8,
+ ConnectionFactory = engine.CreateFactory(),
+ },
+};
+
+byte[] chunk = Encoding.ASCII.GetBytes(new string('x', chunkBytes - 1) + "\n");
+
+var threads = new Thread[config.ReactorCount];
+
+for (int i = 0; i < threads.Length; i++)
+{
+ var reactor = new Reactor(i, config);
+
+ reactor.QuicHandle = (r, conn) =>
+ new Http3Connection(conn).RunStreamedResponseAsync(async (request, writer) =>
+ {
+ bool endless = request.Path.Span.SequenceEqual("/feed"u8);
+ bool upload = request.Path.Span.SequenceEqual("/upload"u8);
+ bool echo = request.Path.Span.SequenceEqual("/echo"u8);
+
+ if (echo)
+ {
+ // BOTH directions at once, which is the shape a proxy actually needs: read a
+ // chunk, write a chunk, and never hold more than one. Neither side can run away
+ // from the other - ReadAsync waits for the peer to send, FlushAsync waits for the
+ // connection to have room - so memory stays flat however large the exchange is.
+ writer.WriteHeaders(Plain());
+
+ if (request.BodyReader is { } duplex)
+ {
+ while (true)
+ {
+ ReadOnlyMemory<byte> part = await duplex.ReadAsync();
+ if (part.IsEmpty)
+ {
+ break; // end of the request body
+ }
+
+ part.Span.CopyTo(writer.GetSpan(part.Length));
+ writer.Advance(part.Length);
+ await writer.FlushAsync();
+ }
+ }
+
+ return;
+ }
+
+ if (upload)
+ {
+ // The other direction: pull the body a chunk at a time rather than waiting for
+ // all of it, so memory is bound by one chunk however large the upload is.
+ long total = 0;
+ if (request.BodyReader is { } body)
+ {
+ while (true)
+ {
+ ReadOnlyMemory<byte> part = await body.ReadAsync();
+ if (part.IsEmpty) break;
+ total += part.Length;
+ }
+ }
+
+ writer.WriteHeaders(Plain());
+ Encoding.ASCII.GetBytes($"{total}\n").CopyTo(writer.GetSpan(24));
+ writer.Advance(Encoding.ASCII.GetByteCount($"{total}\n"));
+ await writer.FlushAsync();
+ return;
+ }
+
+ writer.WriteHeaders(Plain(endless));
+
+ for (int n = 0; endless || n < chunkCount; n++)
+ {
+ chunk.CopyTo(writer.GetSpan(chunk.Length));
+ writer.Advance(chunk.Length);
+
+ // Returns once the chunk is queued, and waits when the connection is at its
+ // send-retention high-water. That await IS the backpressure.
+ await writer.FlushAsync();
+ }
+ });
+
+ threads[i] = new Thread(reactor.Run) { Name = $"reactor-{i}" };
+ threads[i].Start();
+}
+
+Console.WriteLine($"[h3-managed-streamed] {config.ReactorCount} reactors, h3 on udp :{config.Quic!.Port} "
+ + $"(pure C#), {chunkCount} x {chunkBytes}-byte chunks, cert {certPath}");
+
+foreach (Thread thread in threads)
+{
+ thread.Join();
+}
+
+static Http3Response Plain(bool eventStream = false)
+{
+ var response = new Http3Response { Status = 200 };
+ response.Headers.Add(("content-type"u8.ToArray(),
+ eventStream ? "text/event-stream"u8.ToArray() : "text/plain"u8.ToArray()));
+ return response;
+}
+ Both directions streamed, in pure C#. The request body is PULLED a chunk at a time through Http3Request.BodyReader, so a large upload is never held whole; the response is PUSHED through Http3ResponseWriter, one DATA frame per flush, so a large download is never built whole. /echo runs both at once - read a chunk, write a chunk - which is what a proxy does. Owning the framing is what makes the push side simple: a chunk is just [0x00][varint length][payload] handed to the QUIC stream, with no data-reader callback to answer and nothing to defer. carries a resume and a drain because nghttp3 pulls instead; this measures 1.32× its throughput on the same 8×1 KiB response.
Http3Request.BodyReader, so a large upload is never held whole; the response is "
+ "PUSHED through Http3ResponseWriter, one DATA frame per flush, so a large "
+ "download is never built whole. /echo runs both at once - read a chunk, write a "
+ "chunk - which is what a proxy does. "
+ "Owning the framing is what makes the push side simple: a chunk is just "
+ "[0x00][varint length][payload] handed to the QUIC stream, with no data-reader "
+ "callback to answer and nothing to defer. "
+ " carries a "
+ "resume and a drain because nghttp3 pulls instead; this measures 1.32× its "
+ "throughput on the same 8×1 KiB response."),
"h3buf": (
"Http3/Buffered", "HTTP/3 · buffered dispatch", "ioxide + ioxide.ngtcp2 + ioxide.nghttp3",
["curl --http3-only -k https://127.0.0.1:8443/"],
diff --git a/src/protocols/ioxide.http3/Http3Connection.Streamed.cs b/src/protocols/ioxide.http3/Http3Connection.Streamed.cs
new file mode 100644
index 00000000..4aa6b769
--- /dev/null
+++ b/src/protocols/ioxide.http3/Http3Connection.Streamed.cs
@@ -0,0 +1,141 @@
+using System.Buffers;
+
+namespace ioxide.http3;
+
+///