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 0x05 are 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 priority header (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 Hints with Link: rel=preload headers 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 h3 in 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 SSLKEYLOGFILE approach 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.