HTTP/2 and HTTP/3
Imagine a browser loading a modern web page. It references two hundred separate objects: HTML, CSS, JavaScript bundles, fonts, and dozens of images. Under classic HTTP and the Web semantics, everything runs over a single TCP connection. The browser can issue only one request at a time per connection. It must wait for each response before reusing that connection efficiently. So browsers historically opened six or more parallel TCP connections per origin just to hide this latency. That workaround wastes server resources and defeats TCP's congestion control assumptions. It still stalls whenever any one connection's packets are delayed. HTTP/2 attacks this problem by keeping TCP as the transport but redesigning the wire format. It multiplexes many logical request-response exchanges over one physical connection. It also compresses the highly redundant header fields that accompany every request. HTTP/3 goes further by replacing TCP itself with QUIC, a transport protocol built on UDP. This means a single lost packet stalls only the stream it belongs to, not every in-flight exchange. Both protocols preserve the request-response semantics that web developers and servers already understand. Same methods, same status codes, same URLs. Yet they completely re-engineer how bytes travel across the network. This matters because latency, not bandwidth, dominates the felt performance of the web for most users. Connection setup and head-of-line blocking are pure latency taxes. HTTP/1.1 pays these taxes on every page load. Content delivery networks depend on these protocols to shave round trips. So do mobile carriers optimizing spotty radio links. So do hyperscale services serving billions of requests per day. Understanding them also illuminates a deeper systems lesson. Sometimes the fastest way to improve a stack is to move a responsibility elsewhere. Stream multiplexing or loss recovery can shift from one layer to another. This article explains the wire-level mechanics of HTTP/2 framing and HPACK header compression. It then walks through QUIC's stream model. It explains why QUIC structurally eliminates transport-level head-of-line blocking. It gives pseudocode for the core algorithms. It works a byte-level trace of header compression. It is honest about the tradeoffs too. This includes where HTTP/2 push failed in practice. It also includes where QUIC's userspace implementation costs CPU.
- HTTP/2 keeps HTTP semantics but introduces a binary framing layer that multiplexes many streams over one TCP connection.
- HPACK compresses headers using a shared static/dynamic table plus Huffman coding, cutting redundant bytes on every request.
- HTTP/2 still suffers TCP-level head-of-line blocking: one lost segment stalls all streams sharing that connection.
- QUIC runs over UDP, implements its own encrypted, multiplexed transport, and gives each stream independent loss recovery.
- HTTP/3 is HTTP semantics mapped onto QUIC streams, replacing HPACK with QPACK to handle out-of-order header delivery.
- These protocols matter operationally for Content Delivery Networks, mobile latency, and connection-setup round-trip elimination.
HTTP/2 fixes application-layer blocking by multiplexing streams over one connection, while HTTP/3 fixes the remaining transport-layer blocking by moving multiplexing and loss recovery into QUIC, per stream.
The idea
HTTP/2 replaces the plain-text, line-delimited HTTP/1.1 message format with a binary framing layer. Every request and response is broken into frames — small typed units such as `HEADERS`, `DATA`, `SETTINGS`, and `RST_STREAM` — each tagged with a stream identifier. A stream is a bidirectional, independent sequence of frames representing one logical request-response exchange. Because frames from many streams can be interleaved on the same TCP connection, HTTP/2 achieves multiplexing: the client can have dozens of requests in flight on a single connection, and the server can respond in any order, without one slow response blocking others' delivery, which is called eliminating application-layer head-of-line blocking. To reduce overhead, HTTP/2 uses HPACK, a header-compression scheme purpose-built for HTTP's redundant, repetitive header fields (cookies, user-agent strings, repeated paths). HPACK maintains a static table of common header fields shared by the protocol specification, plus a dynamic table that each side builds incrementally from headers actually seen on that connection, so that a header sent once can later be referenced by a small integer index instead of retransmitted in full. HTTP/2's remaining weakness is structural: it still runs over a single TCP connection, and TCP delivers bytes to the application in strict sequence order. If one TCP segment is lost, the kernel withholds all subsequent bytes from every HTTP/2 stream until the lost segment is retransmitted and arrives — a phenomenon called transport-layer head-of-line blocking. Multiplexing at the application layer cannot fix a blocking guarantee enforced by the transport layer beneath it.
HTTP/3 solves this by discarding TCP for QUIC, a transport protocol that runs over UDP but reimplements TCP-like reliability, congestion control, and flow control in user space, bundled with mandatory TLS 1.3 encryption of essentially the whole packet (including most transport metadata). Crucially, QUIC's stream abstraction is native to the transport: each stream has its own byte sequence numbers and its own loss recovery, so a lost UDP packet carrying stream A's data delays only stream A — streams B, C, and D continue delivering already-arrived data to the application immediately. HTTP/3 is therefore best understood as HTTP/2's semantics (multiplexed streams, compressed headers via a QUIC-adapted scheme called QPACK) re-hosted on a transport designed from scratch to make head-of-line blocking a per-stream, not per-connection, event.
How it works
HTTP/2 connection and stream lifecycle. A client opens one TCP connection (usually via TLS with ALPN negotiating "h2"), then both sides exchange a `SETTINGS` frame establishing parameters like maximum concurrent streams and initial flow-control window size. Streams are identified by odd integers if client-initiated, even if server-initiated, and every stream progresses through a well-defined state machine (idle → open → half-closed → closed), letting either endpoint reset a stream mid-flight with `RST_STREAM` without tearing down the connection. Frames from independent streams are freely interleaved on the wire; the receiver demultiplexes purely by reading each frame's stream-ID field. ``` FRAME LAYOUT (HTTP/2, 9-byte common header): Length (24 bits) -- payload length Type (8 bits) -- HEADERS, DATA, SETTINGS, RST_STREAM, ... Flags (8 bits) -- e.g. END_STREAM, END_HEADERS R + StreamID (1 + 31 bits) Payload (variable) ```
HPACK header compression — encoding algorithm. ``` function HPACK_ENCODE(headers, dynamic_table): output = [] for (name, value) in headers: if (name, value) found at index i in STATIC_TABLE or dynamic_table: emit indexed-header-field(i) # 1 byte or so elif name found at index i (value differs): emit literal-header-with-incremental-indexing(i, value) insert (name, value) at FRONT of dynamic_table evict from BACK of dynamic_table while table_size > SETTINGS_max else: emit literal-header-field(name, value) # both huffman-coded insert (name, value) at FRONT of dynamic_table evict from BACK of dynamic_table while table_size > SETTINGS_max return output ``` ``` function HPACK_DECODE(byte_stream, dynamic_table): headers = [] while byte_stream not empty: b = read_next_field(byte_stream) if b is indexed-header-field(i): headers.append(lookup(i, STATIC_TABLE, dynamic_table)) elif b is literal-with-incremental-indexing: (name, value) = decode_literal(b) headers.append((name, value)) insert (name, value) at FRONT of dynamic_table evict while table_size > SETTINGS_max else: # literal without indexing / never-indexed headers.append(decode_literal(b)) return headers ``` The decoder's dynamic table is built incrementally from the same instructions the encoder sends — there is no separate synchronization message; correctness relies on both sides processing headers on a stream in the exact order sent, which is guaranteed because HPACK instructions for a connection must appear in a single logical stream of `HEADERS`/`CONTINUATION` frames processed in order (this ordering requirement becomes the crux of why HTTP/3 needed a different scheme — see QPACK below).
QUIC connection establishment and streams. QUIC combines the transport and cryptographic handshake: a client can complete both a QUIC connection and TLS 1.3 key exchange in one round trip (0-RTT is possible for resumed connections, sending application data immediately, at the cost of weaker replay protection). Each QUIC stream carries an independent, ordered byte sequence with its own flow-control window; packets multiplex frames from many streams, but loss recovery operates per stream: ``` function QUIC_SEND(stream_id, data): packet = new_packet() packet.append_frame(STREAM_FRAME(stream_id, offset, data)) packet.number = next_packet_number() encrypt_and_send(packet) track_in_flight(packet.number, contains_stream=stream_id) function QUIC_ON_ACK(ack_frame): for pn in acked_packet_numbers(ack_frame): mark_acked(pn) update_congestion_window(pn) function QUIC_ON_LOSS_DETECTED(packet_number):
# only the STREAM_FRAMEs actually lost need retransmission --
# data already received on OTHER streams is delivered to the
# application unconditionally, with no blocking for frame in original_frames(packet_number): if frame.type == STREAM_FRAME: resend_stream_data(frame.stream_id, frame.offset, frame.data) reduce_congestion_window() ``` The key structural fact: because each `STREAM_FRAME` names its own `stream_id` and byte `offset`, a receiver can reassemble and deliver stream S1 to the HTTP/3 layer the instant S1's bytes arrive contiguously — independent of whether stream S2's packet was lost and is still being retransmitted.
QPACK. HTTP/3 cannot reuse HPACK directly, because HPACK assumes headers arrive in the exact order encoded (a strict FIFO instruction stream), but QUIC streams can be delivered to the application out of order relative to each other (though each stream itself stays in order). QPACK solves this by moving dynamic-table updates onto a dedicated pair of unidirectional QUIC streams (encoder stream and decoder stream), separate from the request/response streams, and by having the decoder explicitly acknowledge which dynamic-table insertions it has processed before the encoder references them — trading a small amount of extra signaling for tolerance of stream reordering. ``` function QPACK_ENCODE(headers, stream_id): for (name, value) in headers: if in static or dynamic table (already ACKed by peer): emit indexed reference on request stream else: send insertion instruction on ENCODER STREAM emit "reference to not-yet-acked entry" with a "required insert count" marker on request stream
# decoder MUST wait until it has processed insertions up to
# required_insert_count before it can safely decode this
# header block -- if those insertions are still in flight,
# decoding blocks ONLY this stream's header block, not others ```
Complexity & correctness
HPACK. Encoding one header field is O(1) amortized when the field is already in the static or dynamic table (a table lookup plus emitting a small integer), and O(L) when the field must be Huffman-encoded literally, where L is the string length. The dynamic table is maintained as a bounded-size FIFO (eviction from the tail when the configured size limit, `SETTINGS_HEADER_TABLE_SIZE`, is exceeded), so table maintenance costs O(1) amortized per insertion using a ring-buffer or doubly linked structure, and total space is bounded by that configured size — typically a few kilobytes — independent of how many requests have been sent. Correctness rests on an invariant: both encoder and decoder dynamic tables are always structurally identical after processing the same prefix of HPACK instructions, because insertion and eviction are deterministic functions of the instruction stream itself, not of any external state. This invariant requires in-order delivery of the instruction stream, which HTTP/2 guarantees because HEADERS frames for a given header block must be contiguous and are carried within the same reliably-ordered TCP byte stream.
QUIC/HTTP-3 stream delivery. Reassembling one stream from received `STREAM_FRAME`s is analogous to TCP reassembly: each stream keeps a sorted structure (interval tree or gap-buffer) of received byte ranges, so inserting a new range and checking for a contiguous prefix ready for delivery costs O(log k) where k is the number of currently-outstanding gaps for that stream — in practice k is tiny (usually 0–2), so this is effectively O(1). The crucial complexity-and-correctness claim is about independence across streams: because the reassembly buffer, sequence space, and acknowledgment/retransmission bookkeeping are all partitioned per stream-ID, an invariant holds — the deliverability of stream Si's next byte range depends only on packets carrying Si's frames, never on packets carrying only Sj's frames (i ≠ j). This is provable directly from the protocol's framing: a lost packet's retransmission timer and congestion-control penalty apply globally to the connection (one congestion window is still shared, a genuine tradeoff), but data delivery to the application for unaffected streams is not gated on that retransmission, unlike TCP where the receive buffer's contiguous-delivery rule is defined over the single global byte stream.
QPACK's blocking bound. QPACK explicitly allows bounded header-decoding delay: a header block referencing dynamic-table entry E can only be decoded once entry E's insertion instruction has arrived on the encoder stream, so if that instruction's packet is lost, decoding of that one request's headers stalls — but this is a deliberately scoped, per-request stall, not a connection-wide one, and implementations can configure a maximum number of streams allowed to block (`SETTINGS_QPACK_BLOCKED_STREAMS`) trading a little compression efficiency (falling back to literal, non-indexed encoding to avoid the dependency) for a hard upper bound on blocking exposure.
Overall tradeoffs, stated honestly:
- QUIC moves congestion control and loss detection into user-space, which costs measurably more CPU per byte than kernel TCP — commonly cited early deployments showed roughly 2× CPU overhead versus TCP+TLS, though optimized implementations (kernel bypass, hardware offload) have narrowed this gap.
- A single shared congestion-control window across all streams in one QUIC connection means an aggressive server still throttles global throughput after loss; per-stream independence solves head-of-line blocking, not congestion fairness.
- HTTP/2 server push was specified to let servers preemptively send resources before the client asks, but in practice most browsers deprecated or removed push support because cache-unaware pushes wasted bandwidth more often than they saved round trips; it is formally still part of the framing layer but functionally dead in mainstream deployment, superseded by techniques like `Link: rel=preload` headers and 103 Early Hints.
Worked example
HPACK trace. Suppose a client sends three requests to the same origin in sequence, each with headers: Request 1: `:method: GET`, `:path: /index.html`, `user-agent: BrowserX/1.0` Request 2: `:method: GET`, `:path: /style.css`, `user-agent: BrowserX/1.0` Request 3: `:method: GET`, `:path: /index.html`, `user-agent: BrowserX/1.0`
| Step | Header | Action | Bytes on wire (approx.) | Dynamic table after |
|---|---|---|---|---|
| Req 1 | `:method: GET` | in STATIC_TABLE (index 2) | 1 byte, indexed | (unchanged) |
| Req 1 | `:path: /index.html` | not in tables → literal + index | ~18 bytes (Huffman) | insert `(:path, /index.html)` |
| Req 1 | `user-agent: BrowserX/1.0` | not in tables → literal + index | ~22 bytes (Huffman) | insert `(user-agent, BrowserX/1.0)` |
| Req 2 | `:method: GET` | static index 2 | 1 byte | unchanged |
| Req 2 | `:path: /style.css` | new value for known name → literal + index | ~15 bytes | insert `(:path, /style.css)`, table now has 3 entries |
| Req 2 | `user-agent: BrowserX/1.0` | exact match in dynamic table → indexed | 1–2 bytes | unchanged |
| Req 3 | `:method: GET` | static index 2 | 1 byte | unchanged |
| Req 3 | `:path: /index.html` | exact match in dynamic table (re-referenced, moved conceptually to "most recently used" for eviction purposes but index recalculated) | 1–2 bytes | unchanged |
| Req 3 | `user-agent: BrowserX/1.0` | indexed | 1–2 bytes | unchanged |
Raw HTTP/1.1-style plaintext headers for these three requests would total roughly 180–220 bytes; the HPACK-encoded equivalent totals on the order of 60–65 bytes, dominated by the first request's literal encodings — subsequent requests referencing the same header fields cost only a few bytes each. This quantifies the core HPACK payoff: savings scale with header redundancy across a connection's request history, which is exactly the common case for a browser fetching many resources from the same origin (shared cookies, shared user-agent, shared authority).
QUIC head-of-line trace. Consider an HTTP/3 page load requesting `style.css` on stream 4 and `app.js` on stream 8, sent as two separate QUIC packets:
| Event | Packet carrying | Outcome |
|---|---|---|
| t=0ms | Packet P1: STREAM_FRAME(stream=4, offset=0, "…css bytes…") | sent |
| t=0ms | Packet P2: STREAM_FRAME(stream=8, offset=0, "…js bytes…") | sent |
| t=20ms | P2 arrives intact | stream 8 bytes delivered to HTTP/3 layer immediately; browser can start parsing `app.js` |
| t=20ms | P1 lost (simulated packet drop) | stream 4 has a gap; browser cannot yet render CSS |
| t=45ms | Loss detected (ACK-based or timer-based), retransmission of P1's stream-4 data sent | only stream 4's data is resent — stream 8 traffic is untouched |
| t=65ms | Retransmitted data arrives | stream 4 completes; CSS applied |
Under HTTP/2-over-TCP with the same packet-loss pattern, the entire TCP receive buffer — carrying both stream 4's and stream 8's interleaved frames — would withhold delivery of all bytes after the lost segment's sequence number, so even though stream 8's application data physically arrived at t=20ms, TCP's in-order delivery contract would not release it to the HTTP/2 layer until the retransmission at t=65ms. That 45ms of needlessly stalled, already-arrived data for an unrelated resource is precisely the gap HTTP/3 closes.
Common pitfalls & traps
| Pitfall | Why it happens | Fix / correct mental model |
|---|---|---|
| Assuming HTTP/2 "solves" head-of-line blocking entirely | Multiplexing removes application-layer blocking but TCP's single ordered byte stream still enforces global ordering | Remember blocking moved down a layer, not away — only QUIC removes the transport-layer instance |
| Treating server push as still widely useful | It was in the HTTP/2 spec and looked appealing on paper | Chrome and other browsers removed/disabled push by default; prefer preload hints or 103 Early Hints |
| Forgetting HPACK requires strict in-order instruction delivery | Confusing it with QPACK, which tolerates reordering | HPACK's dynamic table sync invariant breaks if instructions arrive out of order — only safe because TCP guarantees order |
| Assuming QUIC's per-stream independence means no shared bottleneck | Conflating loss-recovery independence with congestion-control independence | One QUIC connection still has one (or a few) shared congestion-control windows; heavy loss throttles the whole connection's throughput even if delivery isn't blocked |
| Ignoring middlebox/firewall UDP blocking as a QUIC deployment risk | Many enterprise networks rate-limit or drop UDP more aggressively than TCP | Production HTTP/3 deployments require a TCP/HTTP-2 fallback path when QUIC is blocked |
| Overestimating 0-RTT safety | 0-RTT lets a client resume and send data before the handshake completes, saving a round trip | 0-RTT data is replayable by an attacker who captures and resends it; servers must restrict 0-RTT to idempotent requests |
Pioneers & history
The story of HTTP/2 and HTTP/3 is less about a single inventor and more about an open, adversarial-collaborative standards process, seeded by an experimental protocol built inside a single company and then generalized by the IETF.
Origins · 1 minMike Belshe and how this was discovered
Where this matters
Every major browser (Chrome, Firefox, Safari, Edge) and the largest web platforms (Google, Facebook, Cloudflare, Fastly, Akamai) support HTTP/2 universally and HTTP/3 increasingly by default, negotiated transparently via TLS ALPN or an `Alt-Svc` header advertising QUIC availability. Content Delivery Networks are the most direct beneficiaries: edge servers terminating thousands of simultaneous client connections gain enormously from HPACK's reduced header overhead and from QUIC's faster connection establishment, since CDN performance is dominated by round-trip count on the client's often-lossy last-mile link (mobile, satellite, congested Wi-Fi). Mobile carriers and video-streaming services favor HTTP/3 specifically because QUIC's per-stream loss isolation matters most exactly where packet loss is common — cellular handoffs, weak signal — the regime where classic TCP head-of-line blocking hurts most visibly as stalled video buffering or frozen page loads. gRPC, used pervasively in microservice architectures, was designed around HTTP/2's multiplexing and streaming semantics from the start. Understanding these protocols is foundational before studying how Content Delivery Networks route and cache traffic, since CDN edge nodes are precisely where QUIC's connection-migration feature (letting a connection survive a client's IP address change, e.g., moving from Wi-Fi to cellular) delivers its biggest real-world win.
Ask the tutor: How does QUIC's connection migration feature work when a mobile device switches from Wi-Fi to cellular mid-connection?
Does HTTP/2 or HTTP/3 change the semantics of HTTP methods, status codes, or URLs?
No. Both protocols are deliberately semantic-preserving: GET, POST, status codes like 200 and 404, headers, and URL structure work exactly as in HTTP/1.1. What changes is purely the wire format and transport, replacing plain-text line-delimited messages with binary frames (HTTP/2) or QUIC stream data (HTTP/3), and replacing verbose headers with compressed HPACK/QPACK representations. This design choice was intentional: it meant existing web applications, server-side frameworks, and browser APIs needed essentially no changes to benefit from the new protocols, since the negotiation (via TLS ALPN) happens transparently below the application code. The practical upshot is that adopting HTTP/2 or HTTP/3 is primarily an infrastructure and server-configuration decision, not an application rewrite.
Why did HTTP/2's server push largely fail in practice?
Server push let a server proactively send resources (like a CSS file) before the client explicitly requested them, aiming to save a round trip. In practice, servers often pushed resources the client already had cached, wasting bandwidth, and browsers had no reliable signal to tell the server "don't bother, I have this cached" before the push had already started transmitting. Measurement studies at companies like Google found push frequently made page loads slower, not faster, because of this cache-blindness and because push competed for bandwidth with the actual critical-path response. Chrome removed push support around 2022; the mechanism remains in the HTTP/2 specification but is functionally unused in mainstream deployments, superseded by `Link: rel=preload` hints and the HTTP 103 Early Hints status code.
Is HTTP/3 always faster than HTTP/2 in practice?
Not universally — it depends heavily on network conditions. On low-loss, low-latency wired connections, HTTP/2 and HTTP/3 perform similarly because there's little head-of-line blocking to eliminate and QUIC's extra CPU cost from userspace processing can be a net negative. HTTP/3's advantages are largest on lossy, high-latency, or mobile networks, where packet loss is common and TCP's global blocking hurts most, and where QUIC's faster handshake (1-RTT or 0-RTT versus TCP+TLS's typical 2-3 RTT setup) saves real wall-clock time. Some enterprise or restrictive networks also block or throttle UDP, forcing clients to fall back to HTTP/2 anyway, so production deployments always retain an HTTP/2-over-TCP fallback path.
What is QPACK and how does it differ fundamentally from HPACK?
QPACK is HTTP/3's header-compression scheme, functionally similar to HPACK (static table, dynamic table, Huffman coding) but re-engineered to tolerate QUIC's out-of-order stream delivery. HPACK assumes header-encoding instructions arrive in exactly the order sent, which TCP guarantees but QUIC's independent streams do not. QPACK moves dynamic-table insertion instructions onto dedicated unidirectional "encoder" and "decoder" streams, separate from request streams, and requires the decoder to explicitly acknowledge which table insertions it has processed. A header block referencing an unacknowledged dynamic-table entry can be decoded only once that entry's insertion arrives, bounding the blocking to that one header block rather than the whole connection.
What exactly is "head-of-line blocking" and why are there two different kinds discussed here?
Head-of-line blocking means a delayed or lost item at the front of a queue prevents processing of items behind it, even if those later items are otherwise ready. HTTP/1.1 without pipelining has application-layer HOL blocking: one slow response blocks subsequent requests on the same connection. HTTP/2 fixes that by multiplexing streams, but introduces (or rather, exposes) transport-layer HOL blocking: TCP's single ordered byte stream means one lost segment blocks all multiplexed streams' data, even data from unrelated streams that has already physically arrived. HTTP/3's QUIC transport removes this second kind by giving each stream independent sequencing, so only genuinely affected streams stall.
Does using HTTP/3 require changes to how a web server is configured or how DNS resolves a domain?
Deploying HTTP/3 typically requires a server that supports QUIC (most major web servers and CDNs now do), TLS certificates (QUIC mandates encryption, unlike optional TLS in earlier HTTP), and firewall/load-balancer rules permitting UDP traffic on the relevant port, which is a nontrivial operational change since much existing infrastructure is TCP-centric. Clients discover HTTP/3 availability via an `Alt-Svc` response header advertising a QUIC-capable endpoint, or via DNS-based signaling using HTTPS resource records, letting a browser attempt QUIC on a subsequent connection or in parallel with TCP, falling back gracefully if UDP is blocked. No changes to application-level DNS A/AAAA records or URL structure are required.
How do multiplexing and header compression interact — does compressing headers undermine multiplexing?
No — they are complementary optimizations at different levels of the protocol. Multiplexing concerns how many independent logical exchanges can be in flight simultaneously and how their frames interleave on the wire; header compression concerns how few bytes each individual exchange's metadata costs. HPACK/QPACK's stateful compression (referencing previously-sent headers by index) actually depends on multiplexing's connection reuse: because many requests share one long-lived connection, the dynamic table accumulates useful entries that later requests reference cheaply, which would not be possible if each request opened a fresh, short-lived connection as in naive HTTP/1.1 usage.