HTTP/2 and HTTP/3 (QUIC)
Same Semantics, New Wire Format
HTTP/2 and HTTP/3 did not change what HTTP means: methods, status codes, headers and caching are as in HTTP and HTTPS. What changed is the wire: HTTP/2 multiplexes binary frames over one TCP connection, and HTTP/3 moves that onto QUIC over UDP. Each step fixes the problems of the one before.
Why HTTP/1.1 Stalls
An HTTP/1.1 connection carries one request at a time: the next request waits for the complete previous response. Pipelining exists in the specification, but responses must still return in order and buggy proxies made it unreliable, so browsers ship with it disabled.
Browsers compensated by opening about six connections per host, each with its own handshakes and slow start. Sites added hacks: domain sharding (static1, static2... for more connections), sprites, and one giant script bundle. Under HTTP/2 these backfire: sharding defeats connection reuse and header compression, and giant bundles invalidate the cache whenever one file changes.
HTTP/2: Frames and Streams
HTTP/2 keeps one TCP connection per origin and splits every message into frames. Each frame carries a 9-byte header: a 24-bit length, a type, flags, and a 31-bit stream ID. A stream is one request-response exchange; frames from different streams interleave freely, so a large image download no longer blocks a small API response.
The h2 library (the protocol engine used by Python HTTP/2 clients such as httpx) can show the exact bytes a client produces without any network:
from h2.config import H2Configuration
from h2.connection import H2Connection
FRAME_TYPES = {0: "DATA", 1: "HEADERS", 2: "PRIORITY", 3: "RST_STREAM", 4: "SETTINGS",
5: "PUSH_PROMISE", 6: "PING", 7: "GOAWAY", 8: "WINDOW_UPDATE", 9: "CONTINUATION"}
conn = H2Connection(H2Configuration(client_side=True))
conn.initiate_connection() # preface + SETTINGS
for path in ["/", "/app.css", "/app.js"]:
stream_id = conn.get_next_available_stream_id() # 1, 3, 5: client streams are odd
conn.send_headers(stream_id, [
(":method", "GET"), (":scheme", "https"), (":authority", "shop.example"),
(":path", path), ("user-agent", "demo/1.0"), ("accept-encoding", "gzip, br"),
], end_stream=True) # GET has no body
wire = conn.data_to_send()
preface = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
assert wire.startswith(preface)
i = len(preface)
while i < len(wire):
length = int.from_bytes(wire[i:i + 3], "big") # 9-byte frame header:
ftype, flags = wire[i + 3], wire[i + 4] # length(3) type(1) flags(1)
stream = int.from_bytes(wire[i + 5:i + 9], "big") & 0x7FFFFFFF # stream id(4)
print(f"{FRAME_TYPES[ftype]:8} stream={stream} flags={flags:#04x} payload={length} bytes")
i += 9 + length
Output with h2 4.x:
SETTINGS stream=0 flags=0x00 payload=42 bytes
HEADERS stream=1 flags=0x05 payload=31 bytes
HEADERS stream=3 flags=0x05 payload=13 bytes
HEADERS stream=5 flags=0x05 payload=13 bytes
Reading it:
- The connection opens with a fixed preface string, then a SETTINGS frame on stream 0, which is reserved for the connection itself (SETTINGS, PING, GOAWAY, connection-level WINDOW_UPDATE).
- Three requests go out back to back on streams 1, 3 and 5 without waiting for any response. Client-initiated streams are odd, server-initiated ones even.
- Flags
0x05are END_HEADERS (0x4) plus END_STREAM (0x1): the headers fit in one frame and there is no request body. - The request line is gone. Method, scheme, host and path travel as pseudo-headers (
:method,:path...), and all header names must be lowercase. - The second and third HEADERS frames are less than half the size of the first. That is header compression at work.
The SETTINGS values and their order differ between browsers and HTTP libraries, which is one way servers fingerprint clients; see HTTP/2 Frames and Header Perfecting.
HPACK: Why Repeated Headers Cost Almost Nothing
Browsers send nearly the same headers on every request: user agent, cookies, accept lists. HPACK compresses them with a static table of 61 common header fields, a dynamic table that remembers headers already sent on this connection, and Huffman coding for literals:
from hpack import Encoder
headers = [(":method", "GET"), (":scheme", "https"), (":authority", "shop.example"),
(":path", "/cart"), ("user-agent", "Mozilla/5.0 (X11; Linux x86_64) Firefox/131.0"),
("cookie", "session=4f9c2a7be1d04e5c9a8f")]
plain = sum(len(n) + len(v) + 4 for n, v in headers) # roughly "name: value\r\n"
enc = Encoder()
for n in range(1, 4):
print(f"request {n}: {plain} bytes as text -> {len(enc.encode(headers))} bytes HPACK")
request 1: 167 bytes as text -> 78 bytes HPACK
request 2: 167 bytes as text -> 6 bytes HPACK
request 3: 167 bytes as text -> 6 bytes HPACK
After the first request, each header is a one-byte index into the dynamic table. HPACK was designed after the CRIME attack showed that general-purpose compression of headers leaks secrets; it only matches whole values, and sensitive headers can be marked never-indexed.
Flow Control, Priorities and Push
Multiplexing needs rules for sharing one pipe:
- Flow control works per stream and per connection with WINDOW_UPDATE frames, from a 65,535-byte initial window. Small windows cap throughput on high-latency links.
- SETTINGS_MAX_CONCURRENT_STREAMS limits parallel requests, commonly to around 100. The "Rapid Reset" attack (CVE-2023-44487) opened streams and cancelled them at once with RST_STREAM, so they never counted against that limit; keep HTTP/2 servers and proxies patched.
- Priorities. The original dependency-tree priority scheme was rarely implemented well and is deprecated in RFC 9113. Its replacement is the simpler
priorityheader (RFC 9218), e.g.priority: u=0, i. - Server push let servers send resources before they were requested. It rarely helped and Chrome removed support; use
103 Early HintswithLink: rel=preloadheaders instead.
HTTP/2 over cleartext (h2c) exists, but browsers only speak HTTP/2 over TLS, negotiated via ALPN in the TLS handshake. h2c appears inside data centres, for example gRPC between services or a load balancer talking to backends with prior knowledge.
The Problem HTTP/2 Could Not Fix
HTTP/2 moved head-of-line blocking down to TCP: one lost segment holds back every stream's later data, though the streams are unrelated. On lossy mobile networks a single HTTP/2 connection can do worse than six HTTP/1.1 connections, because one loss stalls everything instead of one sixth of it. The fix required a new transport, since middleboxes made changing TCP itself impractical.
HTTP/3 and QUIC
QUIC implements reliable, multiplexed streams in user space over UDP, with TLS 1.3 built into its handshake. HTTP/3 is HTTP mapped onto QUIC streams. The transport basics (UDP, connection migration) are in TCP vs UDP; the parts that matter for HTTP:
- Per-stream loss recovery. A lost packet delays only the streams whose data it carried.
- Faster setup. TCP plus TLS 1.3 needs two round trips before the first request byte; QUIC needs one. With a resumption ticket, 0-RTT sends the request in the first flight, with the same replay risk described in the TLS lesson, so servers must accept 0-RTT only for idempotent requests.
- QPACK instead of HPACK. HPACK assumes header blocks arrive in order, which QUIC streams no longer guarantee. QPACK moves dynamic-table updates to dedicated streams so a lost packet on one request does not block decoding of others.
- Encrypted transport metadata. Acknowledgments and most header fields are encrypted, so middleboxes cannot ossify the protocol, and packet numbers are never reused, making loss detection unambiguous.
- Amplification limits. Until the client's address is validated, servers send at most three times what they received, so QUIC is a poor reflection amplifier.
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| Transport | TCP | TCP | QUIC over UDP |
| Requests per connection | one at a time | many, multiplexed | many, multiplexed |
| Head-of-line blocking | per connection | TCP-level, all streams | per stream only |
| Header compression | none | HPACK | QPACK |
| Round trips before request (new connection, TLS 1.3) | 2 | 2 | 1 |
| TLS | optional | required by browsers | always (built in) |
| Survives network change | no | no | yes (connection IDs) |
How Clients Find HTTP/3
A browser cannot know in advance that a server speaks QUIC on UDP 443. Two mechanisms tell it:
import httpx # pip install "httpx[http2]"
with httpx.Client(http2=True, timeout=10) as client:
for url in ["https://www.cloudflare.com/", "https://www.google.com/", "https://example.com/"]:
r = client.head(url)
print(f"{url:32} {r.http_version:9} alt-svc: {r.headers.get('alt-svc', '-')[:60]}")
https://www.cloudflare.com/ HTTP/2 alt-svc: h3=":443"; ma=86400
https://www.google.com/ HTTP/2 alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
https://example.com/ HTTP/2 alt-svc: -
The Alt-Svc header advertises h3 on port 443 and caches that knowledge for ma seconds, so a browser's first visit typically uses HTTP/2 and later connections race QUIC against TCP. h3-29 is a leftover draft version. The second mechanism is the DNS HTTPS record, which advertises the protocols before any connection is made:
import dns.resolver # pip install dnspython
for rr in dns.resolver.resolve("cloudflare.com", "HTTPS"):
print(rr.to_text())
1 . alpn="h3,h2" ipv4hint="104.16.132.229,104.16.133.229" ipv6hint="2606:4700::6810:84e5,2606:4700::6810:85e5"
dig cloudflare.com HTTPS shows the same record from the shell.
To see what your browser actually used, enable the Protocol column in DevTools' Network tab (h2, h3, http/1.1). From the shell, curl --http3 -I https://site/ works only if curl -V lists HTTP3 under Features; many distribution builds lack it.
Running It Yourself
Enabling HTTP/2 is a one-line change in most servers. HTTP/3 needs a little more, shown here for nginx 1.25.1 or later:
server {
listen 443 ssl;
listen 443 quic reuseport; # UDP listener for HTTP/3
http2 on;
ssl_certificate /etc/ssl/site/fullchain.pem;
ssl_certificate_key /etc/ssl/site/privkey.pem;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
}
The operational catches:
- Open UDP 443 in every firewall and security group. Clients fall back silently, so a blocked path shows up only as missing
h3in DevTools. - Load balancers must route QUIC by connection ID, not by the four-tuple, or migrating clients break.
- CPU cost is higher than TCP, which enjoys decades of kernel and NIC offloads; large deployments rely on UDP segmentation offload and larger socket buffers.
- Debugging is harder because even transport headers are encrypted. Wireshark decrypts QUIC with the same
SSLKEYLOGFILEapproach used for TLS.
Choosing
Enable HTTP/2 wherever you terminate TLS, and undo old sharding hacks when you do. Add HTTP/3 where users are on mobile, lossy or distant networks, where the saved round trip and per-stream recovery are felt. If a CDN terminates your traffic it likely offers both already, and your origin can keep speaking HTTP/1.1 or HTTP/2 to it.
Practice
Load a site you run with DevTools open and the Protocol column visible. Note which requests used h2 or h3, then reload: if the first load was h2 and the second h3, you have watched Alt-Svc work. Finally, extend the frame-parsing script to decode a server's SETTINGS frame and print each setting's ID and value.