status
this chapter is in active development
expect live edits and rapid iteration (except for when i am really busy with other stuff) while this material is written.
status
this chapter is in active development
expect live edits and rapid iteration (except for when i am really busy with other stuff) while this material is written.
http/2 was supposed to fix everything. multiplexed streams, header compression, server push. and it did fix a lot, until a single lost packet on the tcp connection froze every stream behind it.
that is head-of-line blocking, and it is baked into tcp's design. tcp sees one ordered byte stream; it has no concept of "stream 3 can proceed while stream 1 waits for a retransmit." quic (RFC 9000) fixes this. http/3 (RFC 9114) is http semantics running on top of it.
a fresh https connection over tcp costs three round trips before the first http request:
on a 100ms rtt link that is 300ms before the browser sees a single byte. tls 1.2 adds another round trip on top.
quic merges transport and crypto into a single handshake. the first flight carries both connection setup and tls ClientHello. one round trip to encrypted application data.
with session resumption, quic supports 0-RTT: the client sends encrypted http data in its very first packet. the server responds before the handshake completes. the catch is replay attacks. anything sent in 0-RTT can be replayed by an attacker, so only idempotent requests (GET, HEAD) belong there. send a POST in 0-RTT and you deserve whatever happens.
tcp identifies connections by a four-tuple: source ip, source port, destination ip, destination port. change any one and the connection dies. walk from wifi to cellular and every tcp connection drops.
quic uses connection ids. opaque tokens that both sides agree on during the handshake. the server identifies your connection by this id, not your ip address. switch networks and the connection survives after a path validation exchange.
connection ids also rotate for privacy. an observer on the old network and an observer on the new network see different ids and cannot trivially link them to the same session.
quic natively multiplexes independent streams within a single connection. each stream has its own sequence space and flow control. lose a packet on stream 0 and streams 1 and 2 keep delivering data to the application.
this is what tcp-based http/2 cannot do. all streams share tcp's single ordered byte stream.
on clean links the difference is negligible. on links with 1-2% loss (mobile, congested wifi, intercontinental paths) it changes the game. a lossy mobile link degrades individual resources (one image loads slowly) instead of freezing the entire page.
quic's loss detection (RFC 9002) improves on tcp in a few ways:
unique packet numbers. every packet gets a monotonically increasing number, even retransmissions. tcp reuses sequence numbers for retransmits, creating ambiguity about which packet an ack refers to. quic eliminates this entirely.
ack ranges. quic acks carry ranges of received packet numbers, similar to tcp's SACK but mandatory and always present.
per-stream retransmission. lost data is retransmitted per-stream, and only the affected stream stalls. unaffected streams continue.
congestion control is still per-connection, not per-stream. quic typically runs cubic or bbr, same algorithms as tcp, same rtt and loss signals. congestion stalls affect delivery scheduling, not the entire byte stream.
there is no unencrypted quic. tls 1.3 is not layered on top; it is woven into the handshake. even the transport headers beyond the connection id are encrypted (in 1-RTT packets). makes middlebox interference much harder.
the initial handshake packets use keys derived from the connection id (effectively public), so ClientHello and ServerHello are not truly secret. but once the handshake completes, everything (ack frames, flow control updates, connection close reasons) is encrypted.
http/3 maps http semantics onto quic streams:
the framing is different from http/2 but the semantics are identical. methods, status codes, headers, trailers all work the same. your application code should not care whether it runs over http/2 or http/3.
clients do not try quic first. the initial connection goes over tcp+tls. the server advertises http/3 via Alt-Svc:
alt-svc: h3=":443"; ma=86400
"I speak http/3 on udp port 443, remember this for 86400 seconds." the next request races a quic connection against tcp. if quic wins, subsequent requests use it. if quic fails (firewalls, broken middleboxes), the client falls back to tcp transparently.
curl -v shows this:
< alt-svc: h3=":443"; ma=93600
force http/3 with curl --http3-only https://example.org -v and you skip the tcp discovery.
udp port 443 is blocked on a surprising number of enterprise networks. some firewalls drop all udp except dns. some nat devices time out udp bindings aggressively (30 seconds vs minutes for tcp). some middleboxes cannot inspect quic traffic and block it on principle.
the alt-svc fallback exists for exactly these networks. http/3 is an optimization, not a requirement. if the quic path is broken, tcp takes over and the user never notices.
quic load balancing is harder than tcp because the five-tuple changes during connection migration. load balancers need to read the connection id to route packets to the right backend. RFC 9312 defines a connection id encoding scheme that embeds routing information, letting load balancers forward without decrypting.
cloud providers handle this: cloudflare, aws cloudfront, and google cloud all support quic termination. if you run your own load balancers, check whether they can route by connection id before enabling quic.
nginx has experimental quic support since 1.25. caddy has solid http/3 out of the box. haproxy does not support quic yet. on the application side, go has quic-go, rust has quinn and quiche (cloudflare's implementation).
for most deployments, the cdn or reverse proxy terminates quic and speaks http/2 or http/1.1 to backends. you get the client-facing latency benefits without changing application servers.
curl --http3-only -v https://cloudflare.com:
* using HTTP/3
* [HTTP/3] [0] OPENED stream for https://cloudflare.com/
* [HTTP/3] [0] [:method: GET]
* [HTTP/3] [0] [:scheme: https]
* [HTTP/3] [0] [:authority: cloudflare.com]
* [HTTP/3] [0] [:path: /]
> GET / HTTP/3
> Host: cloudflare.com
> User-Agent: curl/8.7.1
> Accept: */*
>
< HTTP/3 301
< location: https://www.cloudflare.com/
same pseudo-headers as http/2, but the transport is quic. wireshark decodes quic if you give it tls secrets via SSLKEYLOGFILE:
SSLKEYLOGFILE=/tmp/keys.log curl --http3-only https://cloudflare.com
open the pcap in wireshark, set the tls pre-master secret log to /tmp/keys.log, and you see individual quic frames, stream ids, ack ranges. without the key file you see udp datagrams and nothing else. that is the point.
quic wins on high latency (saving 1-2 round trips on 200ms+ rtt links), packet loss (independent stream delivery), mobile clients (connection migration), and cdn edge (last-mile is where loss and latency are worst).
quic barely matters for datacenter east-west (sub-millisecond rtt makes handshake savings irrelevant), long-lived connections (websockets or grpc streams amortize the handshake over minutes), or udp-hostile networks (if quic cannot connect, you are on tcp anyway).
the overhead (userspace processing, larger headers) is measurable but rarely the bottleneck. the operational cost hits harder: debugging tools are less mature, kernel bypass techniques like io_uring do not help with userspace protocol stacks, and the protocol is newer so operational knowledge is thinner.
treat quic as the default for client-facing https and tcp as the sensible choice for everything behind the load balancer. choosing a transport formalizes this.