HTTP/3 and QUIC: Secure Transport over UDP, Alt-Svc and HTTP/3 in .NET
HTTP/3 uses QUIC, which runs over UDP instead of TCP: TLS 1.3 built in, ordering per stream, one RTT less on a new connection. Discovery via Alt-Svc and the DNS HTTPS record, QuicConnection.IsSupported=false on macOS, Kestrel HTTP/3 working with libmsquic in a Linux container, why HTTP/3 isn't faster on loopback, and the full RTT it saves at 50 ms RTT, with real measurements.
Version note: The code in this post was built with .NET 10 (ASP.NET Core 10 Kestrel); the HTTP (Hypertext Transfer Protocol) version 3 experiments ran in a mcr.microsoft.com/dotnet/sdk:10.0 (Ubuntu 24.04) container with libmsquic 2.6.3 installed from Microsoft's package repository. The QUIC (the new transport protocol under HTTP/3) description follows RFC (Request for Comments) 9000, 9001 and 9002, and the HTTP/3 description RFC 9114.
Guess First
To turn on HTTP/3 in Kestrel, setting an endpoint's protocols to HttpProtocols.Http1AndHttp2AndHttp3 is enough. If you do that on a Mac and ask for HTTP/3 with HttpClient, what happens: does the request go over HTTP/3, does the app fail at startup, or does something else happen quietly?
Second question: because HTTP/3 merges the TCP (Transmission Control Protocol) and TLS (Transport Layer Security) handshakes, it saves one RTT (round-trip time) on a new connection. Measured on the same machine over loopback, is HTTP/3's first request faster than HTTP/2's?
QUIC: Reliable Transport over UDP
At the end of the previous post we left the problem HTTP/2 couldn't solve: because all streams share a single TCP byte stream, a single lost packet stops them all. Fixing that meant changing TCP; but TCP lives in the operating system kernel and in countless routers, firewalls and NAT (Network Address Translation) devices along the way, and changing it takes years. That's why QUIC was built on UDP (User Datagram Protocol): every device passes UDP, and QUIC itself runs as a library on the application side rather than in the operating system.
QUIC (RFC 9000) rebuilds the guarantees TCP provides: reliable and ordered delivery, flow control, congestion control (RFC 9002). The difference is that ordering is per stream, not per connection. If a packet of stream 3 is lost, only stream 3 waits; data arriving for streams 1 and 5 is handed to the application right away. That's the real fix for head-of-line blocking at the transport layer.
The second big difference is encryption. TLS 1.3 is embedded in QUIC (RFC 9001): almost the whole QUIC packet, including most of the header, is encrypted. That means devices in between can't "understand" QUIC and change its behavior; interference of exactly this kind is why TCP couldn't be changed for years.
The third is the connection ID. A TCP connection is identified by four values (source IP (Internet Protocol), source port, destination IP, destination port); when a phone moves from Wi-Fi to mobile data and its IP changes, the connection breaks. A QUIC connection is identified by its own connection ID and can continue even when the client's address changes: connection migration.
The Handshake: 1-RTT and 0-RTT

With TCP and TLS 1.3, on a new connection the request goes out at the end of the second RTT: one RTT for the TCP handshake, one for TLS. In QUIC the transport and cryptographic handshakes run in the same packets: the client's first packet (Initial) carries the TLS ClientHello, and the server's reply carries both the QUIC connection parameters and the TLS messages from ServerHello to Finished. The client sends the first HTTP/3 request together with its own Finished. With a server you've connected to before, the 0-RTT we saw for TLS in the fifth post exists here too: the request goes in the first flight, with the same replay risk and the same rule (idempotent requests only).
HTTP/3 (RFC 9114) carries HTTP semantics on top of this transport. HTTP/2's streams are now QUIC's streams; the frame structure got simpler. For header compression, QPACK (RFC 9204) is used instead of HPACK (HTTP/2's header compression). The reason is ordering again: HPACK's dynamic table assumes headers arrive in order; since streams progress independently in QUIC, QPACK keeps that dependency under control by carrying table updates on separate streams.
Discovering HTTP/3: Alt-Svc and the DNS HTTPS Record
How does a client know a server speaks HTTP/3? HTTP/3 doesn't use a separate URL (Uniform Resource Locator) scheme; the address is still https://. There are two discovery paths. The first is the Alt-Svc header: the server adds "the same service is also available as HTTP/3 on this port" to an HTTP/1.1 or HTTP/2 response. Kestrel does this itself when HTTP/3 is on; sending an HTTP/2 request with curl to the server in the container:
$ curl -sv --cacert /certs/ca.crt https://127.0.0.1:5492/whoami 2>&1 | grep -iE "alt-svc|HTTP/2 200"
< HTTP/2 200
< alt-svc: h3=":5492"; ma=86400
h3=":5492" means "HTTP/3 over UDP on port 5492 of the same host," and ma=86400 says this information is valid for a day. This path has a gap: the first request always goes over TCP, and HTTP/3 can only be used from the second connection on. The second path is the DNS (Domain Name System) HTTPS (HTTP Secure) record we decoded for cloudflare.com in the fourth post: if its alpn value contains h3, the browser can try QUIC on the very first connection.
UDP isn't open on every network. Many corporate firewalls allow only TCP 443. That's why browsers race QUIC against TCP or quietly fall back to TCP when QUIC fails; the user doesn't notice, only HTTP/3's gain is lost.
To try HTTP/3 on the command line you need a curl build with HTTP/3 support. Neither the curl on this machine nor the one in the container has it:
$ curl -sS --http3 https://127.0.0.1:5492/whoami
curl: option --http3: the installed libcurl version doesn't support this
HTTP/3 in .NET 10: Kestrel and HttpClient
The server side is one line; on my demo server it's turned on with an environment variable:
options.Listen(bindAddress, 5492, o =>
{
// With HTTP3=1 the same port also listens for HTTP/3 over UDP (QUIC needs libmsquic)
o.Protocols = Environment.GetEnvironmentVariable("HTTP3") == "1"
? HttpProtocols.Http1AndHttp2AndHttp3
: HttpProtocols.Http1AndHttp2;
o.UseHttps(certificate);
});
HTTP/3 is off by default and, according to Microsoft's documentation, should always be enabled together with HTTP/1.1 and HTTP/2, because some networks don't pass UDP. Kestrel uses the MsQuic library for QUIC; if the platform doesn't provide it, HTTP/3 is quietly disabled and the endpoint keeps working with the other protocols. I wrote a demo that tests HTTP/3 and QUIC support on the client side:
public static async Task RunAsync(string url, string caPath)
{
#pragma warning disable CA2252 // System.Net.Quic platform check
Console.WriteLine($"QuicConnection.IsSupported = {QuicConnection.IsSupported}");
#pragma warning restore CA2252
// 1) The first request goes over HTTP/2; the server advertises HTTP/3 with Alt-Svc
using var discovery = new HttpClient(LocalCaTrust.CreateHandler(caPath))
{
DefaultRequestVersion = HttpVersion.Version20,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact,
};
using (var response = await discovery.GetAsync(url))
{
var altSvc = response.Headers.TryGetValues("Alt-Svc", out var values) ? string.Join(", ", values) : "(none)";
Console.WriteLine($"HTTP/{response.Version} {(int)response.StatusCode} Alt-Svc: {altSvc}");
}
// 2) Ask for HTTP/3 directly and time a new connection
foreach (var version in new[] { HttpVersion.Version20, HttpVersion.Version30, HttpVersion.Version20, HttpVersion.Version30 })
{
using var client = new HttpClient(LocalCaTrust.CreateHandler(caPath))
{
DefaultRequestVersion = version,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact,
};
var first = Stopwatch.StartNew();
using var r1 = await client.GetAsync(url);
var firstMs = first.Elapsed.TotalMilliseconds;
var second = Stopwatch.StartNew();
using var r2 = await client.GetAsync(url);
Console.WriteLine($"HTTP/{r1.Version}: first request (new connection) {firstMs:F1} ms, second request {second.Elapsed.TotalMilliseconds:F1} ms, body={(await r2.Content.ReadAsStringAsync()).Trim()}");
}
}
The first guessing question, against a Kestrel running on macOS:
$ dotnet Bookstore.Net.Lab.dll http3 https://127.0.0.1:5492/whoami certs/ca.crt
QuicConnection.IsSupported = False
HTTP/2.0 200 Alt-Svc: (none)
HTTP/2.0: first request (new connection) 52.2 ms, second request 1.1 ms, body=blue
Unhandled exception. System.Net.Http.HttpRequestException: Requesting HTTP version 3.0 with version policy RequestVersionExact while unable to establish HTTP/3 connection.
On this machine (macOS, .NET 10.0.13) QUIC isn't supported: IsSupported is false, the server doesn't advertise Alt-Svc, and the client asking for HTTP/3 with RequestVersionExact stops with a clear error. Microsoft's HTTP/3 documentation ties support to MsQuic's platform requirements: Windows 11 or Windows Server 2022 and later on Windows, the libmsquic package on Linux.
I ran the same code in a Linux container. The .NET SDK (Software Development Kit) image doesn't ship with libmsquic; I added Microsoft's package repository, installed version 2.6.3, started the server with HTTP3=1 and ran the client in the same container:
$ dotnet /lab/Bookstore.Net.Lab.dll http3 https://127.0.0.1:5492/whoami /certs/ca.crt
QuicConnection.IsSupported = True
HTTP/2.0 200 Alt-Svc: h3=":5492"
HTTP/2.0: first request (new connection) 3.1 ms, second request 2.4 ms, body=h3-kestrel
HTTP/3.0: first request (new connection) 53.0 ms, second request 0.6 ms, body=h3-kestrel
HTTP/2.0: first request (new connection) 1.4 ms, second request 0.2 ms, body=h3-kestrel
HTTP/3.0: first request (new connection) 2.3 ms, second request 0.2 ms, body=h3-kestrel
HTTP/3 works. The same port number is now listened on over two separate transports; in the container's socket table, 5492 (1574 in hexadecimal) shows up on both TCP and UDP:
$ cat /proc/net/tcp /proc/net/udp6 | grep -i ":1574 " | cut -c1-80
0: 0100007F:1574 00000000:0000 0A 00000000:00000000 00:00000000 00000000
3292: 00000000000000000000000000000000:1574 00000000000000000000000000000000:00
3292: 00000000000000000000000000000000:1574 00000000000000000000000000000000:00
...
On the TCP side there's a single LISTEN socket; on the UDP side there are ten sockets opened by MsQuic, which is also the number of processor cores this container sees.
This setup isn't enough to try it in a browser: as Microsoft's documentation also notes, browsers don't accept self-signed development certificates over HTTP/3. For local testing, HttpClient with HttpRequestMessage.Version = 3.0 is the right way.
Is HTTP/3 Faster on Loopback?
The answer to the second guessing question is in the output above, and it's "no." The first HTTP/3 connection took 53 milliseconds; almost all of that is a one-time cost inside the process: loading MsQuic and JIT (Just-In-Time) compiling the QUIC layer. Once warm, a new HTTP/3 connection takes 2.3 milliseconds and a new HTTP/2 connection 1.4 milliseconds. Because the RTT on loopback is microseconds, the RTT that QUIC saves doesn't show up here; what remains is the CPU (Central Processing Unit) cost of QUIC doing encryption and packet processing in user space.
HTTP/3's gain shows up where RTTs are large. To measure it, I used Linux's tc netem in my own container to add a 25-millisecond delay to every packet on the loopback interface, making the RTT 50 milliseconds (this required starting the container with the NET_ADMIN capability and installing the iproute2 package). The same demo:
$ tc qdisc add dev lo root netem delay 25ms
$ dotnet /lab/Bookstore.Net.Lab.dll http3 https://127.0.0.1:5492/whoami /certs/ca.crt
QuicConnection.IsSupported = True
HTTP/2.0 200 Alt-Svc: h3=":5492"
HTTP/2.0: first request (new connection) 164.4 ms, second request 56.1 ms, body=lab-kestrel
HTTP/3.0: first request (new connection) 195.9 ms, second request 52.9 ms, body=lab-kestrel
HTTP/2.0: first request (new connection) 167.2 ms, second request 56.7 ms, body=lab-kestrel
HTTP/3.0: first request (new connection) 108.6 ms, second request 56.7 ms, body=lab-kestrel
The picture changed. On a new HTTP/2 connection the first response takes 167 milliseconds: one RTT for TCP, one for TLS, one for the request, i.e. three times 50 plus a little processing. Warm HTTP/3 takes 108.6 milliseconds: two RTTs. The measurement shows exactly the one-RTT difference from the diagram. The second requests on an open connection take a single RTT, about 55 milliseconds, on both protocols. Streams not holding each other up on a lossy network is a separate gain; I didn't measure that in this environment.
In Production
There are places where HTTP/3 isn't worth it. For service-to-service traffic inside a datacenter, the RTT is already under a millisecond and packet loss is next to nothing; QUIC's extra CPU cost can outweigh the gain. HTTP/2 is usually the better choice on that path. HTTP/3's real place is internet-facing endpoints, especially those serving mobile users, and that edge is often a CDN (Content Delivery Network) or load balancer, not the application server.
Prepare the infrastructure for UDP. Open UDP 443 in the firewall and make sure the load balancer supports QUIC; QUIC connections should be routed by connection ID rather than IP and port, otherwise connection migration and even normal traffic can break. Alt-Svc's ma value decides how long browsers remember this information; take it into account if you plan to turn HTTP/3 off.
Revisit observability. Because QUIC packets are encrypted, packet analysis tools built for TCP and network devices' metrics see far less in QUIC traffic; diagnosis has to rely on metrics on the application and browser side.
Common Pitfalls
Enabling HTTP/3 alone. For users on networks that block UDP, always enable it together with HTTP/1.1 and HTTP/2.
Generalizing the result from your development machine. macOS has no QUIC; on Linux, if libmsquic isn't installed, Kestrel quietly turns HTTP/3 off. If you don't see the Alt-Svc header, check the platform first.
Deciding from a loopback measurement. HTTP/3's gain comes from RTT and loss; locally you only measure its CPU cost.
Expecting HTTP/3 in a browser with a self-signed certificate. Browsers don't allow it; use HttpClient for local tests.
Enabling 0-RTT for every request. TLS's replay risk is the same in QUIC.
A Small Experiment
★☆☆ Turn on the Protocol column in your browser's DevTools Network panel and find requests that come back as h3 on a large site. Is the first request h2 and the later ones h3?
★★☆ Start the server with HTTP3=1 in a Linux container without installing libmsquic. What do the application log and the Alt-Svc header show? Then install the package and try again.
★★★ Run this post's demo with DefaultRequestVersion = HttpVersion.Version30 and RequestVersionOrLower, and observe whether the client falls back to HTTP/2 when the server's UDP port is closed (by restarting the server with HTTP/3 off).
We've seen every stop on the series map: addresses, TCP, DNS, TLS, HTTP/1.1, caching, HTTP/2 and HTTP/3. In the last post we bring them together: we split a slow request's time into phases and find where the problem is with curl -w, DevTools Timing and .NET metrics.