Skip to content
Shiny.NET

Protocols

Everything above the transport is shared. The same routes, binders, middleware, authorization and handlers serve every protocol version, because each request mapper turns what arrived into the HttpRequest the rest of the server already understands.

Situation Protocol
TLS, ALPN agreed h2 HTTP/2
TLS, anything else HTTP/1.1
Cleartext, opens with the HTTP/2 connection preface HTTP/2
Cleartext, anything else HTTP/1.1
QUIC listener HTTP/3

Over TLS the negotiation already happened, and overriding it would break the client’s expectations. Over cleartext the only legitimate signal is the client opening with the connection preface (“prior knowledge”, RFC 9113 §3.3), so the first bytes are peeked — not consumed — and compared against it.

The default, and what a browser on a phone will use against a local server. Keep-alive, pipelining, chunked request and response bodies, and the 101 handover that WebSockets need.

Limits — request line size, header count and total size, body size, keep-alive and header timeouts, requests per connection — are on Options.Limits. See Configuration.

On by default and negotiated per connection, so turning it on cannot break an HTTP/1.1 client.

builder.Configure(o =>
{
o.Http2.Enabled = true; // default
o.Http2.AllowCleartext = true; // default — prior-knowledge h2c
o.Http2.MaxConcurrentStreams = 100;
o.Http2.InitialStreamWindowSize = 96 * 1024;
o.Http2.InitialConnectionWindowSize = 1024 * 1024;
o.Http2.MaxFrameSize = 16 * 1024;
o.Http2.MaxHeaderListSize = 32 * 1024;
});

A full server-side stack written on the same primitives as everything else:

  • HPACK with static and dynamic tables and the RFC 7541 Huffman code. The decoder keeps a dynamic table because the peer’s indices depend on it; the encoder deliberately keeps none, since an encoder table that drifts from the peer’s produces headers that decode to something else entirely.
  • Frames and streams — DATA, HEADERS, CONTINUATION, SETTINGS, PING, WINDOW_UPDATE, RST_STREAM, GOAWAY, PRIORITY. One read loop owns the socket and fans frames out; each stream runs the pipeline concurrently, and all writes funnel through a serializing frame writer.
  • Flow control per stream and per connection, in both directions, with windows topped up in chunks rather than per frame.

The connection window is larger than one stream’s, so several concurrent uploads do not have to take turns.

Not implemented: server push (deprecated and gone from browsers), trailers, and the h2c Upgrade: handshake — prior knowledge only, which is what every real h2c client actually uses. The HPACK encoder also does not Huffman-code its output: emitting raw literals is always legal and costs a few bytes per response, while the decoder handles Huffman because every real client sends it.

QUIC over UDP, on its own listener — HTTP/3 does not run on TCP at all.

var app = builder.Build();
// Before StartAsync: advertising Alt-Svc adds middleware, and the pipeline is
// composed once the server starts serving.
await using var h3 = await app.ListenHttp3Async(o =>
{
o.Port = 5001;
o.Certificate = certificate;
});
await app.StartAsync();

ListenHttp3Async binds the QUIC endpoint and adds the Alt-Svc header to the TCP listener’s responses. A client will not use HTTP/3 without being told it exists, and there is no other discovery mechanism.

Option Default
Address / Port Loopback / 5001
Certificate / CertificateSelector required — QUIC has no plaintext mode
MaxBidirectionalStreams 100 (each is one in-flight request)
MaxUnidirectionalStreams 10
MaxFieldSectionSize 32 KB
IdleTimeout 130 seconds
AltSvc Built from Port — h3=":5001"; ma=86400

The implementation covers varints (RFC 9000 §16), frames (RFC 9114) and QPACK (RFC 9204) with the full 99-entry static table. The QPACK dynamic table is deliberately refused — the server announces a capacity of zero, which is spec-legal and removes the entire class of head-of-line bugs that come with encoder streams, insert-count tracking and blocked request streams. Responses use static references where they exist and literals otherwise, so the encoder stream stays empty and nothing can ever block waiting for a table to catch up.

Headers sent after the body, which is the only way to report something a response could not know before it started writing — a checksum, a row count, or the status of an RPC whose failure surfaced halfway through the stream. gRPC is built on them.

app.MapGet("/export", async ctx =>
{
ctx.Response.DeclareTrailer("X-Row-Count");
var rows = await WriteCsvAsync(ctx.Response.Body);
ctx.Response.AppendTrailer("X-Row-Count", rows.ToString());
});

Response.Trailers stays writable for the whole response, unlike Response.Headers, and is read at completion. Each protocol carries them its own way:

How they travel
HTTP/1.1 The trailer section after the terminating chunk
HTTP/2 A trailing HEADERS frame with END_STREAM
HTTP/3 A second HEADERS frame after the last DATA

On HTTP/2 and HTTP/3, a response that never wrote a body sends its trailers in the one header block it does send. That shape has a name in gRPC — Trailers-Only — and is how a call that failed before producing anything reports its status.

A page handler that spends 200 ms on a database query leaves the browser idle for those 200 ms. A 103 Early Hints response (RFC 8297) sent first tells it which stylesheets, scripts and fonts the page will need, so it downloads them while the handler is still working:

app.MapGet("/", async ctx =>
{
await ctx.Response.SendEarlyHintsAsync(
"</app.css>; rel=preload; as=style",
"</app.js>; rel=preload; as=script",
"<https://fonts.example.com>; rel=preconnect"
);
var model = await LoadDashboardAsync(ctx.RequestAborted); // the slow part
// Repeat the hints on the final response: a client or proxy is free to discard a 1xx.
ctx.Response.Headers.Append(HeaderNames.Link, "</app.css>; rel=preload; as=style");
await ctx.Response.WriteTextAsync(Render(model), "text/html; charset=utf-8");
});

Each argument is one Link header value. Browsers act on preload and preconnect hints, and only for page navigations. The general form is SendInformationalResponseAsync(int statusCode, HeaderDictionary? headers), which sends any 1xx except 101 (for example a 102 Processing during a long operation). You can send several, and they go out in the order you call them. Neither method touches Response.Headers or StatusCode.

How it travels
HTTP/1.1 An interim status line and header block (HTTP/1.1 103 Early Hints) before the final head
HTTP/1.0 Not sent. HTTP/1.0 has no 1xx responses, so a 1.0 client would read the hint as the final response. The call returns false
HTTP/2 A HEADERS frame with :status: 103 and no END_STREAM, HPACK-encoded
HTTP/3 A HEADERS frame with :status: 103 before the final one, QPACK-encoded

Both methods return ValueTask<bool>: true when the response was sent, and false when nothing was sent (HTTP/1.0, or a response that is not attached to a connection). Calling either after the response has started throws InvalidOperationException. A 1xx after the final status line would corrupt the response, so this is a bug in the handler’s ordering and it is reported as one. A status outside 1xx, or 101, throws ArgumentOutOfRangeException. A header value containing CR or LF, or a Content-Length/Transfer-Encoding header, throws ArgumentException.

How it works with the rest of the server:

  • Expect: 100-continue is still answered by the server itself on HTTP/1.1, before your handler runs. Early Hints go out after it.
  • Response compression and other body wrappers are bypassed. The interim response goes directly to the connection and is never compressed.
  • Output caching stores only the final response. A cache hit does not run the handler, so it sends no hints.
  • Tunnels (relay, SSH, Azure Relay) carry the connection’s bytes unchanged, so hints reach the public client the same way they do on a direct connection.
  • The in-memory test client and HttpClient in general discard every 1xx, so they only see the final response. To assert on hints, read the raw bytes or frames.
  • The reverse proxy does not forward a 103 from a destination. SocketsHttpHandler consumes interim responses and never returns them to the caller. If you want hints on a proxied route, send them from a middleware that runs before MapProxy/MapReverseProxy. The proxy writes the final response only after the destination answers, so there is still time.

On a device, almost always HTTP/1.1: a browser will not speak HTTP/2 without TLS, and a device-local server usually serves cleartext on loopback. HTTP/2 shows up when the app’s own HttpClient asks for it, when a gRPC-style client connects, or once you put a certificate on the network-facing endpoint. See TLS & Certificates.