One page, three protocols
A web page is rarely one file. The browser fetches index.html, reads it, and finds a style sheet, a script and a dozen images it must fetch too. How long the page takes depends less on bandwidth than on round trips and on how well the protocol keeps the link busy while requests and responses cross the network. HTTP/1.1 (1997), HTTP/2 (2015) and HTTP/3 (2022) carry exactly the same requests and responses (methods, status codes, headers, bodies); what changed is how they are put on the wire.
The canvas loads the same page three times at once, on one clock, over three identical copies of the network: an RTT of 100 ms and a server uplink that sends one packet every 5 ms. Each lane shows, from the top: its connections (HTTP/1.1) or streams (HTTP/2, HTTP/3), the last 24 packets received (coloured = handed to the browser, grey = received but held back, ✕ = a hole), the wire (server on the right, client on the left; data above, ACKs and requests below; a red border marks a retransmission), and a waterfall like the one in the browser's DevTools: grey while a request waits for a connection or a handshake, light blue from the request until the first byte, the resource's colour while it arrives, orange while it waits for its own lost packet, red while its bytes are held back because of another resource's lost packet, and a black tick when it is complete. Under the waterfall each lane shows its page load time, the request header bytes and data packets sent so far, and, once a packet was lost, how many were lost and resent and how long resources stalled: HOL stall is time spent held behind another resource's lost packet, own-loss wait time spent waiting for a resource's own lost packet, both summed over the rows. Every step is 10 ms.
The page in numbers (Demo 1)
With no loss the page takes 790 ms over HTTP/1.1, 595 ms over HTTP/2 and 495 ms over HTTP/3. HTTP/2 and HTTP/3 send exactly the same 36 response packets back to back; HTTP/3 is ahead by exactly one round trip, the one its handshake saves (the index.html request leaves at 100 ms instead of 200 ms). HTTP/1.1 is behind for two reasons you can see on the waterfall: connections #2–#6 are opened only when the HTML has arrived, and each costs its own 200 ms of TCP and TLS handshakes; and each connection carries one request at a time, so between one response and the next request the server's link sits idle for a round trip.
HTTP/1.1: text, keep-alive, one request at a time
An HTTP/1.1 request is plain text: a request line, one Name: value line per header, a blank line. The page's request for /img/3.png is exactly 270 bytes, and every request repeats every header: the 45-character User-Agent, Accept, the cookie. Keep-alive (the default since 1.1) lets one TCP connection carry request after request, but only one at a time: the connection is busy until the whole response has arrived. The response has no identifier, so the client knows which request it answers only from the order.
Pipelining allowed the client to send several requests without waiting, but the server must answer in the same order. One slow response blocks every response behind it, even ones that are ready: HTTP-level head-of-line blocking (Demo 3: app.js takes 300 ms to generate, and every image waits behind it). Worse, some proxies and servers mishandled pipelined requests, so browsers never turned it on. Instead they open up to six connections per host, each with its own handshakes (and, in reality, its own slow start), and sites spread their files over several host names (domain sharding) to get more. Demo 7 shows what a single connection would cost: one full round trip per resource.
HTTP/2: binary frames and streams
HTTP/2 keeps the semantics and changes the framing. Everything is sent in frames with a 9-byte header: length (24 bits), type, flags and a 31-bit stream identifier. The main frame types are HEADERS (a compressed header block), DATA (body bytes), SETTINGS (connection parameters, sent first by both sides), WINDOW_UPDATE (flow control), RST_STREAM (cancel one stream without closing the connection), PING and GOAWAY. A stream is one request and its response; the client numbers its streams 1, 3, 5, … and each goes through the states idle → open → half-closed (the client has sent all of its request, END_STREAM) → closed. Because every frame names its stream, the frames of many responses can be interleaved on one connection: this is multiplexing, and it removes HTTP-level head-of-line blocking: in Demo 3 the images flow while app.js is still being generated.
Each stream, and the connection as a whole, has its own flow-control window (65 535 bytes by default), so a slow reader of one stream cannot stall the others. HTTP/2 also had a tree of stream priorities; it was complicated, servers implemented it inconsistently, and RFC 9113 deprecated it in favour of the simpler Priority header of RFC 9218 (an urgency 0–7 and an "incremental" flag). The page's servers use no priorities: they send one packet of each stream in turn (round robin), which is why style.css, the first resource, is not done first. Server push let a server send a response the client had not asked for yet; it rarely helped (the browser often had the file cached already) and Chrome removed it in 2022.
How do client and server agree on HTTP/2? Inside the TLS handshake: the ClientHello's ALPN extension lists h2 and http/1.1, and the server picks one, at no extra round trip (see How an HTTPS Connection Is Established). Browsers speak HTTP/2 only over TLS.
HPACK: header compression
Most of a request's headers are the same every time. HPACK (RFC 7541) replaces them with small table indices. A static table of 61 common entries is built in (index 2 is :method GET, 7 is :scheme https, 5 is :path /index.html; 58 is the name user-agent). Each side also keeps a dynamic table (4 096 bytes by default): a header sent as a "literal with incremental indexing" is added to it, at index 62, pushing older entries to 63, 64 and so on. A header found in either table costs one byte. Values can also be Huffman-coded (about 30 % shorter); the page leaves Huffman off so that the sizes can be counted by hand.
Take the page's request for /img/3.png: as the first request on a connection it costs 180 bytes (1 byte each for :method and :scheme, then each literal is a 1-byte name index, a 1-byte length and the value: :path 12, :authority 13, user-agent 47, accept 27, accept-encoding 19, accept-language 16, referer 22, cookie 22). After the first request the dynamic table holds, newest first, 62 cookie, 63 referer, 64 accept-language, 65 accept-encoding, 66 accept, 67 user-agent, 68 :authority and 69 :path /img/3.png. Every later request costs 21 bytes: two static and seven dynamic-table hits of 1 byte each and a new 12-byte :path, plus the 9-byte frame header. Each lane's second line under the waterfall counts the request header bytes sent so far. In Demo 5 (30 requests) HTTP/1.1 sends 8 387 bytes of request headers and HTTP/2 1 094 (the first request, for index.html, finds :path /index.html in the static table and costs 169 bytes). A compression table shared by all requests was also a security problem: CRIME showed that compressing a secret cookie together with attacker-chosen text reveals the cookie byte by byte, which is why HPACK compresses only whole header values, never across them.
TCP head-of-line blocking
HTTP/2's streams are independent, but they all travel in one TCP connection, and TCP delivers one ordered byte stream. When a packet is lost, the receiver's kernel keeps every later packet in its out-of-order queue until the retransmission fills the hole, even if those packets belong to entirely different streams. Demo 2 drops the 5th response packet after the HTML (img/3.png's first packet, due at 440 ms). Three later packets are acknowledged with duplicate ACKs, the third reaching the server at 505 ms; the retransmission goes to the head of the queue and arrives at 560 ms. Until then, style.css (complete at 480 ms without loss), img/1.png and img/2.png are held back: the red band crosses every row at the same time, about 1 s of stall summed over the resources. The page's loss detection is TCP's classic one; see How TCP Congestion Control Finds the Bandwidth for fast retransmit, timeouts and what the loss does to the sending rate (which this page leaves out).
HTTP/1.1 suffers too, but only on one connection: in Demo 2 the lost packet is app.js's third, and only one packet follows it on connection #1, so there are not three duplicate ACKs and the server waits for its retransmission timer (RTO = 300 ms after the last new ACK at 585 ms). app.js is complete at 940 ms, while the images simply move to the other five connections. Short responses rarely produce three duplicate ACKs; real stacks shorten this wait with tail-loss probes and RACK.
HTTP/3 and QUIC
To remove the last head-of-line blocking, the ordering must move out of the transport's single byte stream, and that could not be done by changing TCP: TCP lives in operating-system kernels and in middleboxes that drop anything unfamiliar. QUIC (RFC 9000) therefore runs over UDP and implements reliability, ordering and congestion control in the application's own library. Its features, as used by HTTP/3 (RFC 9114):
- Streams with their own ordering. QUIC numbers every packet, and each frame inside says which stream and which byte offset it carries. The receiver reassembles each stream on its own, so a lost packet stalls only the streams whose data it carried. In Demo 2 only img/3.png waits (orange: its own lost packet); style.css is complete as without loss.
- Packet numbers are never reused. A retransmission carries the lost frames in a new packet with a new number, so an acknowledgement is never ambiguous (TCP cannot tell whether an ACK answers the original or the retransmission). Loss detection (RFC 9002) declares a packet lost when a packet sent three later is acknowledged, or on a time threshold and a probe timeout.
- One handshake. The TLS 1.3 handshake is carried inside QUIC's own Initial and Handshake packets, so a new connection is ready after 1 RTT instead of TCP's 1 RTT plus TLS's 1 RTT. A returning visitor with a session ticket can send its request in the very first packet (0-RTT, Demo 4: 395 ms). 0-RTT data can be replayed by an attacker who records it, so browsers send only safe, idempotent requests that way and servers may refuse it. Over TCP, browsers do not send TLS early data, so HTTP/1.1 and HTTP/2 gain nothing from a return visit here.
- Connection IDs. A QUIC connection is named by an ID chosen by the endpoints, not by the four-tuple of addresses and ports. When a phone moves from Wi-Fi to the mobile network its address changes, but the connection continues after a path check, without a new handshake (connection migration).
- QPACK. HPACK assumes that header blocks arrive in the order they were sent, since each one can change the dynamic table the next one refers to. QUIC streams arrive in any order, so HTTP/3 uses QPACK (RFC 9204): table insertions travel on a dedicated one-way encoder stream, acknowledgements on a decoder stream, and a header block says which insertions it needs. The page counts QPACK's sizes with HPACK's rules.
- Discovery. A browser learns that a server speaks HTTP/3 from an
Alt-Svc: h3=":443"header in an earlier HTTP/2 response or from the DNS HTTPS record (see How DNS Resolution Works). Some networks block or throttle UDP, so browsers race or fall back to HTTP/2 over TCP.
On the server side, nginx enables HTTP/2 with http2 on; and HTTP/3 with listen 443 quic reuseport; next to the usual listen 443 ssl;, plus an add_header Alt-Svc 'h3=":443"; ma=86400'; so that browsers find it (see How nginx Handles an HTTP Request).
Summary
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| transport | TCP (+ TLS) | TCP + TLS | QUIC over UDP (TLS 1.3 inside) |
| requests per connection at once | 1 (pipelining: in order, off) | many streams | many streams |
| connections per host | up to 6 | 1 | 1 |
| headers | text, repeated every time | HPACK | QPACK |
| new connection before the first request | TCP 1 RTT + TLS 1 RTT | TCP 1 RTT + TLS 1 RTT | 1 RTT; 0 RTT when resuming |
| a lost packet stalls | its own connection | every stream | only its own stream(s) |
| survives an address change | no | no | yes (connection IDs) |
What the page leaves out
- Congestion control and slow start. The windows are large from the start. In reality each new TCP connection starts with about 10 packets per round trip (IW 10), so six HTTP/1.1 connections get 60 packets into the first round trip against HTTP/2's 10, which narrows the gap in Demo 1; see TCP congestion control. Each lane also has its own copy of the network, whereas six connections would take a larger share of a shared bottleneck.
- Loss recovery is the same simple rule for TCP and QUIC: every packet is acknowledged at once; a packet is lost when three packets sent after it on the same connection are acknowledged, or when the timer
RTO = RTT + max(2 RTT, 200 ms)fires (restarted by every ACK that advances the oldest unacknowledged data, doubled after each timeout). There are no tail-loss probes, RACK or QUIC probe timeouts, which would shorten the HTTP/1.1 timeout. Counting "later" in send order also catches a lost retransmission, which classic TCP could only notice by its timer. - The servers interleave streams round robin without priorities; real browsers ask for CSS and scripts first. Flow-control windows never fill up.
- The HTTP/3 lane assumes the browser already knows the server speaks HTTP/3 (DNS HTTPS record or a remembered
Alt-Svc); on a true first visit Chrome usually starts with HTTP/2. - All packets are full-size (the 1 200-byte QUIC and 1 460-byte TCP payloads count the same); control packets (handshakes, requests, ACKs) take no time on the link and are never lost; the "5th response packet" and the random losses (2 %, a fixed seed) hit the same packet numbers in every lane. TLS certificate size and QUIC's anti-amplification limit are ignored.
- The HTML is parsed only once it is complete; parsing, rendering and server work take no time, except the slow app.js option (300 ms). HPACK is counted without Huffman coding and QPACK with HPACK's rules. The page's HOL-hatching is drawn as solid red (the drawing library has no hatching).
See also How an HTTP Connection Is Established (one TCP connection packet by packet, keep-alive and the six-connection limit) and How an HTTPS Connection Is Established (the TLS 1.3 handshake, ALPN and 0-RTT).