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:
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: 4812HOL 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:
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_UPDATEframes. 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:
- 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.
- 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
Retrytoken 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:
curl -sv --http2 -o /dev/null https://www.cloudflare.com 2>&1 | grep ALPN* ALPN: curl offers h2,http/1.1
* ALPN: server accepted h2HTTP/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:
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/2Without 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:
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:
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.
Related
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.
- Follow-up: “How is it mitigated?” - patched servers that count resets
per connection and send
- “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.