Backend / Protocols / HTTP / 02_http_versions.md

HTTP versions: 1.1, 2 and 3

Updated 4 interview angles 11 min read source
On this page10
  1. HTTP/1.1: one request at a time per connection
  2. HTTP/2: many streams on one TCP connection
  3. HTTP/3: streams moved into the transport
  4. The progression in two tables
  5. How a client picks a version
  6. What it means for a Python service
  7. Where it breaks in production
  8. The trade not taken
  9. Related
  10. Interview angle

HTTP versions: 1.1, 2 and 3

Article.

Every HTTP version since 1.1 keeps the same semantics - methods, status codes, headers - and changes only how messages travel. Each rewrite removed the bottleneck the previous version could not: first the cost of a connection per request, then requests queuing behind each other on a connection, then a lost packet stalling everything on that connection.

The thread through all three is head-of-line (HOL) blocking: work that is ready but waits behind work that is not.

The specs today: HTTP semantics in RFC 9110, HTTP/1.1 in RFC 9112, HTTP/2 in RFC 9113 (2022, replacing RFC 7540 from 2015), HTTP/3 in RFC 9114 (2022) on QUIC, RFC 9000 (2021).

HTTP/1.1: one request at a time per connection

HTTP/1.0 opened a TCP connection per request. HTTP/1.1 made connections persistent by default, made the Host header mandatory (so one IP can serve many sites), and added chunked transfer encoding for bodies of unknown length. The format is plain text:

text
GET /app.css HTTP/1.1
Host: shop.example.com
Accept-Encoding: gzip, br

HTTP/1.1 200 OK
Content-Type: text/css
Content-Length: 4812

HOL blocking at the HTTP layer

On one HTTP/1.1 connection a client must wait for a response before the next request can be answered: responses carry no request ID, so they have to come back in order. Pipelining - sending several requests without waiting - was in the spec, but one slow response still blocked the ones behind it, and buggy proxies mixed responses up. Browsers shipped it disabled.

The workaround was more connections: browsers open up to 6 per origin. That costs 6 handshakes, 6 slow-starting congestion windows, and gives only 6 requests in flight. Domain sharding, sprite sheets, CSS/JS concatenation and inlining all existed to squeeze a page through those 6 lanes.

HTTP/2: many streams on one TCP connection

HTTP/2 keeps HTTP’s semantics and replaces the text format with binary frames. Each request/response pair is a stream with an ID (odd numbers for client-opened streams), and frames from different streams interleave on a single connection:

text
one TCP connection, frames in flight:

 HEADERS s1 | HEADERS s3 | DATA s3 | DATA s1 | DATA s3
 └ stream 1 = GET /app.css    stream 3 = GET /api ┘

What that changes

  • Multiplexing. Hundreds of requests share one connection, and a slow response no longer blocks a fast one. One connection also means one handshake and one warmed-up congestion window instead of six cold ones.
  • HPACK header compression (RFC 7541). Headers repeat on every request - cookies, user agent. HPACK keeps a table of headers already sent on the connection and replaces repeats with small indexes. It is not gzip on purpose: compressing secrets alongside attacker-chosen text leaks them (the CRIME attack).
  • Flow control per stream and per connection, with WINDOW_UPDATE frames. The initial window is 65,535 bytes - more on that below.
  • TLS in practice. Browsers speak HTTP/2 only over TLS, negotiated with ALPN as h2. Cleartext HTTP/2 (h2c) exists for internal traffic; gRPC often uses it with “prior knowledge”.

Two features did not survive. The priority tree of RFC 7540 was deprecated by RFC 9113 and replaced by RFC 9218 Extensible Priorities, a simple header such as Priority: u=3, i. Server push was rarely a win - servers pushed resources the browser already had cached - and Chrome removed it in version 106 (2022). Its job now goes to 103 Early Hints (RFC 8297) plus preload.

The HOL blocking HTTP/2 could not remove

HTTP/2’s streams are a fiction TCP does not know about. TCP delivers one ordered byte stream, so when a segment carrying stream 1’s data is lost, everything after it waits in the kernel for the retransmission - stream 3’s frames included, even though they arrived intact.

On a clean network this never shows. Under packet loss it gets worse than HTTP/1.1: one connection means one congestion window, and a single loss cuts the sending rate for every request at once, where six HTTP/1.1 connections would have lost only one lane. Mobile networks are where this hurts.

HTTP/3: streams moved into the transport

HTTP/3 maps the same HTTP semantics onto QUIC, a transport that runs over UDP and implements streams itself. A lost packet now stalls only the stream whose data it carried; other streams keep being delivered.

Why build on UDP instead of fixing TCP

This is the mechanism question interviews reach for. TCP could not be changed in practice, for two reasons:

  1. Ossification. Firewalls, NATs and load balancers parse TCP headers and drop or rewrite what they do not recognise. TCP Fast Open, a 2014 change to send data in the SYN, never deployed widely because middleboxes broke it. Any new TCP feature hits the same wall.
  2. TCP lives in the kernel. A change ships only as fast as every OS and device updates its kernel - years.

QUIC runs in user space on top of UDP, which middleboxes pass through without understanding, and it encrypts almost all of its own headers so that nothing on the path can grow dependent on them. A browser or server ships a new QUIC version by shipping new code.

What QUIC does differently

  • Streams in the transport. Retransmission and ordering are per stream. HOL blocking survives only within one stream, where bytes stay ordered.
  • One handshake. TLS 1.3 is built into QUIC (RFC 9001): transport and crypto setup finish together in 1 round trip, versus 2 for TCP + TLS 1.3. A resumed session can send safe requests (GET, HEAD) as 0-RTT early data - replayable, which is why RFC 8470 limits it to safe methods.
  • Connection IDs. A QUIC connection is identified by an ID, not by the IP/port 4-tuple, so it survives a client moving from Wi-Fi to mobile data or a NAT rebinding its port. TCP connections die on either.
  • Unambiguous loss detection. Packet numbers only increase and are never reused for a retransmission, so every RTT sample is exact (RFC 9002).
  • Anti-amplification. Before a client’s address is validated, a server may send at most 3x the bytes it received, so spoofed-source UDP cannot turn it into a DDoS amplifier. A Retry token validates the address first.
  • QPACK instead of HPACK (RFC 9204). HPACK assumes headers arrive in order on one byte stream. QPACK sends its table updates on separate streams so a lost packet cannot corrupt the table, at the price of a stream occasionally waiting for an update it references.

The per-version round-trip budget - 3 round trips to the first byte on TCP + TLS 1.3, 2 on HTTP/3, 1 on a reused connection - is worked through in What happens when you enter a URL.

The progression in two tables

Version Removed Left behind
HTTP/1.1 a connection per request HOL blocking per connection; 6-lane hacks
HTTP/2 HTTP-layer HOL blocking TCP HOL blocking under packet loss
HTTP/3 TCP HOL blocking ordering within one stream; UDP’s costs
Version Transport Setup before the first request
HTTP/1.1 TCP, TLS optional TCP + TLS 1.3: 2 round trips
HTTP/2 TCP + TLS (ALPN h2) TCP + TLS 1.3: 2 round trips
HTTP/3 QUIC over UDP, TLS 1.3 built in 1 round trip; 0 on resumption

How a client picks a version

On TCP, the version is agreed inside the TLS handshake with ALPN (RFC 7301): the client offers h2 and http/1.1, the server picks one, and HTTP never has to upgrade. curl shows the exchange:

bash
curl -sv --http2 -o /dev/null https://www.cloudflare.com 2>&1 | grep ALPN
text
* ALPN: curl offers h2,http/1.1
* ALPN: server accepted h2

HTTP/3 cannot be offered that way, because it is not TCP at all. A client learns about it from an Alt-Svc: h3=":443" response header (RFC 7838) and uses QUIC on a later connection, or from an HTTPS record in DNS (RFC 9460), which lets it use QUIC on the very first visit. Browsers race QUIC against TCP and fall back silently when UDP is blocked; the URL walk-through linked above shows both paths.

What it means for a Python service

Most applications never see the version: an edge - CDN, load balancer or nginx - terminates HTTP/2 and HTTP/3 from browsers and talks HTTP/1.1 with keep-alive to the app over a fast, low-loss internal network. That matters because the Python servers differ (as of 2026-10):

Server Speaks
uvicorn HTTP/1.1 and WebSocket only
Hypercorn HTTP/1.1, HTTP/2, HTTP/3 (via aioquic)
Granian HTTP/1.1 and HTTP/2

So HTTP/3 for a FastAPI app means terminating it at nginx or the CDN. On the client side, httpx does HTTP/2 when asked and has no HTTP/3; requests stays on HTTP/1.1:

python
import httpx  # pip install "httpx[http2]"

with httpx.Client(http2=True) as client:
    r = client.get("https://www.cloudflare.com")
    print(r.http_version)  # HTTP/2

Without http2=True, the same code prints HTTP/1.1. The one internal hop that must be HTTP/2 is gRPC: it relies on HTTP/2 streams and trailers end to end (gRPC's HTTP/2 Basis).

At the nginx edge

Both versions take a few lines. Note the directive change - listen 443 ssl http2 is deprecated since nginx 1.25.1:

nginx
server {
    listen 443 ssl;               # TCP: HTTP/1.1 and HTTP/2
    listen 443 quic reuseport;    # UDP: HTTP/3
    http2 on;

    ssl_protocols TLSv1.2 TLSv1.3;  # QUIC itself always uses TLS 1.3
    add_header Alt-Svc 'h3=":443"; ma=86400' always;
}

Where it breaks in production

HTTP/2 Rapid Reset

In October 2023, attackers broke records for request floods - Google measured 398 million requests per second - with Rapid Reset (CVE-2023-44487). The trick: open a stream and cancel it at once with RST_STREAM, thousands of times per connection:

text
HEADERS s1, RST_STREAM s1, HEADERS s3, RST_STREAM s3, ...

A cancelled stream no longer counts against MAX_CONCURRENT_STREAMS, so the limit never trips, while the server still pays to parse each request and often to start processing it. The fixes: patched servers that count resets and send GOAWAY to clients that cancel too much.

Related attacks followed - the 2024 CONTINUATION flood (endless header frames that never finish) and MadeYouReset (CVE-2025-8671, server-side resets provoked by malformed frames). An HTTP/2 edge is a parser exposed to the internet; keep it patched.

Long-distance transfers stuck at a low speed

HTTP/2 flow control starts each stream and the whole connection with a 65,535-byte window. A sender may have at most one window of unacknowledged data in flight per round trip, so throughput is capped at window / RTT: 64 KB / 100 ms is about 650 KB/s, however fast the link. Most clients and servers raise the window early with SETTINGS and WINDOW_UPDATE; one that does not makes large downloads mysteriously slow across continents.

Queuing behind the stream limit

nginx allows 128 concurrent streams per HTTP/2 connection by default (http2_max_concurrent_streams). A gRPC client that funnels all calls through one channel queues the 129th request until a stream frees up - latency that looks like a slow server. The fix is a pool of channels or a higher limit.

One connection, one backend

HTTP/2 and gRPC connections are long-lived and carry every request a client makes. An L4 load balancer balances connections, not requests, so one busy client stays pinned to one backend forever and new backends get no traffic. Balance at L7, where each request is routed separately (Load Balancers).

HTTP/3’s own costs

  • UDP is blocked on some corporate and hotel networks. Clients fall back to TCP, so HTTP/3 must always have HTTP/2 beside it.
  • CPU. Kernel TCP gets segmentation and checksums offloaded to the NIC; QUIC encrypts and processes every packet in user space. Figures of 2-3x the CPU per byte are often quoted; UDP GSO/GRO narrow the gap.
  • Connection migration meets L4 balancing. A balancer that hashes the 4-tuple sends a migrated connection to the wrong server. Routing by connection ID needs QUIC-aware load balancing.

The trade not taken

HTTP/3 all the way to the origin. It sounds consistent, but inside a data centre losses are near zero and round trips are sub-millisecond, so TCP’s HOL blocking never shows - while QUIC still costs more CPU, needs UDP open between tiers, and makes packet captures harder to read. Its wins - one-round-trip setup, migration, loss isolation - all happen on the last mile.

So terminate HTTP/3 and HTTP/2 at the edge, run HTTP/1.1 with keep-alive pools inside, and choose internal HTTP/2 only where multiplexing is the point: gRPC, or many concurrent calls between two services.

Interview angle 4

  • “What problem did HTTP/2 solve, and what did it leave?” - HTTP/1.1 could answer one request at a time per connection, so browsers opened six connections and pages were bundled to fit. HTTP/2 multiplexes streams over one connection, which removes HOL blocking at the HTTP layer. It left HOL blocking in TCP: one lost segment stalls every stream.
    • Follow-up: “So on a lossy mobile network, is HTTP/2 always faster than HTTP/1.1?” - no. One connection has one congestion window, and a loss slows every request at once; six HTTP/1.1 connections lose only one lane. That gap is what HTTP/3 was built for.
  • “Why does HTTP/3 run over UDP? Why not fix TCP?” - middleboxes ossified TCP (TCP Fast Open could not get through them) and TCP lives in kernels that update slowly. QUIC implements streams, loss recovery and TLS 1.3 in user space on UDP and encrypts its headers so it cannot ossify the same way.
    • Follow-up: “What do you lose by leaving TCP?” - UDP is blocked on some networks, so you keep HTTP/2 as a fallback; QUIC costs more CPU per byte; and L4 balancers hashing the 4-tuple break connection migration.
  • “A handful of clients pin your HTTP/2 edge at 100% CPU. What is it?” - likely Rapid Reset: streams opened and cancelled immediately, which never trips the concurrent-stream limit but makes the server parse every request.
    • Follow-up: “How is it mitigated?” - patched servers that count resets per connection and send GOAWAY, plus rate limits at the edge.
  • “Does your FastAPI app need to support HTTP/3?” - no. The edge terminates HTTP/2 and HTTP/3; the app speaks HTTP/1.1 with keep-alive behind it, and uvicorn cannot speak anything else anyway.
    • Follow-up: “When must the internal hop be HTTP/2?” - gRPC, which needs HTTP/2 streams and trailers end to end, and which then needs L7 load balancing because one connection carries every call.