Module 07: HTTP/1.1 vs HTTP/2 vs HTTP/3
Why this matters
Everything you've learned so far — methods, headers, status codes, caching
— is the semantics of HTTP, and here's the good news: those semantics
are identical across HTTP/1.1, HTTP/2, and HTTP/3. A GET is a GET,
a 404 is a 404, Cache-Control works the same. What changes between
versions is purely how the bytes are framed and moved on the wire — the
performance engineering underneath the same application-level contract.
So why care, if the semantics don't change? Because the wire format
determines real-world speed, and the differences explain things you'll hit
constantly: why bundling all your JS into one file was a performance hack
that HTTP/2 made unnecessary (even counterproductive); why a single
dropped packet can stall an entire HTTP/2 connection but not HTTP/3; why
your curl -v sometimes says HTTP/2 and sometimes HTTP/1.1; why
HTTP/3 needs UDP and what "QUIC" is. When someone says "we moved to HTTP/2
and cut page load time," you'll know exactly which mechanism did it.
This is also where the module 01 detail — "one connection can carry many requests" — gets its full story: HTTP/1.1's keep-alive, HTTP/2's multiplexing, and HTTP/3's independent streams are three increasingly clever answers to "how do we send many requests fast over one connection?"
Concepts
The one thing that stays the same
HTTP semantics — the meaning of methods, headers, status codes, bodies — are version-independent. When we say "HTTP/2," we mean a new transport framing for the same messages. Your FastAPI handler code doesn't change at all between versions; the server/proxy negotiates the version and frames the bytes. This is why you could learn all of modules 01-06 without ever mentioning versions: they're the semantic layer, and it's stable.
What changes: how requests/responses are encoded into bytes, multiplexed over a connection, and transported (TCP vs. UDP). Pure performance.
HTTP/1.1: text, one-at-a-time per connection
HTTP/1.1 (from 1997, still everywhere) frames messages as the plain text you hand-typed in module 01: a request line, header lines, blank line, body. Two performance characteristics define it:
- Persistent connections (keep-alive): a TCP connection stays open for multiple request/response pairs instead of a fresh TCP+TLS handshake per request (module 00's expensive setup). This was HTTP/1.1's big win over HTTP/1.0.
- Head-of-line (HOL) blocking at the request level: on one connection, requests are answered in order, one at a time. Request 2 can't be sent until request 1's response comes back (pipelining was specced but broken in practice and effectively unused). So a slow response blocks everything behind it on that connection.
HTTP/1.1 on one connection — serial; a slow req 1 blocks 2 and 3:
├─[ req1 ═══════ slow ═══════ ]
│ ├─[ req2 ══ ]
│ ├─[ req3 ══ ]
└──────────────────── time ──────────────────────────►
Browsers worked around this by opening 6+ parallel TCP connections per origin — 6 requests at once, but each connection still serial, and each paying its own handshake cost. This is why front-end performance advice for years was "reduce the number of requests": bundle all JS into one file, sprite all images into one image, inline small assets. Those were all hacks around HTTP/1.1's one-request-at-a-time limit.
HTTP/2: binary framing and true multiplexing
HTTP/2 (2015) keeps the exact same semantics but changes everything below:
- Binary framing: messages are encoded as binary frames, not text.
You can't
telnetan HTTP/2 server and type a request — it's not human-readable on the wire anymore. (curl still shows you the semantic request/response; it's decoding the binary for you.) - Multiplexing over one connection: many requests and responses are broken into frames and interleaved over a single TCP connection, each request/response pair being a stream with its own ID. Request 2 no longer waits for request 1 — dozens of requests fly concurrently on one connection. This eliminates HTTP/1.1's request-level HOL blocking and makes the "open 6 connections" and "bundle everything" hacks unnecessary (bundling can even hurt, because a change to one file invalidates the whole bundle's cache — module 06).
- Header compression (HPACK): headers repeat enormously across
requests (same
User-Agent,Cookie,Acceptevery time). HPACK compresses them, saving bandwidth on every request. - Server push (mostly dead): HTTP/2 could "push" resources the client hadn't asked for yet. It proved hard to use well and is deprecated/ removed in practice — don't rely on it.
The catch: HTTP/2 multiplexes over one TCP connection, and TCP guarantees in-order delivery of the whole byte stream. So if a single packet is lost, TCP holds back all the streams' data until it's retransmitted — even streams that had all their bytes. This is TCP-level head-of-line blocking: HTTP/2 solved HOL blocking at the HTTP layer but inherited it at the TCP layer. On a lossy network (mobile, congested Wi-Fi), one lost packet stalls every concurrent request.
HTTP/2 — multiplexed streams over ONE TCP connection:
stream1 ▓▓░▓▓ ┐
stream2 ▓▓▓▓▓ ├─ interleaved frames, concurrent...
stream3 ▓▓▓▓░ ┘ but a lost TCP packet (░) stalls ALL streams
HTTP/3 — independent streams over QUIC/UDP:
stream1 ▓▓░▓▓ ← only stream1 waits for its retransmit
stream2 ▓▓▓▓▓ ← keeps flowing
stream3 ▓▓▓▓▓ ← keeps flowing
HTTP/3: QUIC over UDP kills TCP head-of-line blocking
HTTP/3 (2022) keeps the same semantics and the same multiplexing idea, but replaces the transport. Instead of TCP, it runs over QUIC, a new transport protocol built on UDP:
- Independent streams: QUIC understands streams at the transport level. A lost packet affecting stream A no longer blocks streams B and C — only stream A waits for its retransmission. This finally eliminates the TCP-level HOL blocking HTTP/2 suffered. On lossy networks the difference is dramatic.
- Faster connection setup: QUIC folds the transport and TLS handshakes together, so establishing a secure connection takes fewer round trips (often 1-RTT, or 0-RTT for resumption) versus TCP's handshake then TLS's handshake (module 00's separate steps 3 and 4). Fewer round trips = lower latency (module 00's lesson).
- Connection migration: a QUIC connection is identified by a connection ID, not the IP/port 4-tuple, so it can survive a network change — walking from Wi-Fi to cellular doesn't drop your connection.
- UDP-based: because it's UDP, it needs no changes to get through most networks, but some restrictive firewalls block UDP, in which case clients fall back to HTTP/2 over TCP.
Encryption is mandatory in HTTP/3 (QUIC has TLS 1.3 built in) — there is no plaintext HTTP/3.
How the version gets chosen
You don't usually pick the version in a header. It's negotiated:
- HTTP/1.1 vs HTTP/2 over TLS is negotiated during the TLS handshake
via ALPN (Application-Layer Protocol Negotiation): the client offers
h2andhttp/1.1; the server picks. (Module 09 covers TLS/ALPN.) - HTTP/3 can't be negotiated in-band the same way (it's a different
transport). A server advertises HTTP/3 availability via the
Alt-Svcresponse header (e.g.Alt-Svc: h3=":443"), and a client that saw it may try HTTP/3 (UDP) on the next connection, falling back to HTTP/2 if UDP is blocked.
So a first visit is often HTTP/2, and the browser upgrades to HTTP/3 on
subsequent connections after seeing Alt-Svc.
The comparison at a glance
| Property | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|---|---|---|
| Wire format | Text | Binary frames | Binary frames |
| Transport | TCP | TCP | QUIC (UDP) |
| Requests per connection | Serial (1 at a time) | Multiplexed (many) | Multiplexed (many) |
| HTTP-level HOL blocking | Yes | No | No |
| TCP-level HOL blocking | N/A (serial) | Yes (one lost packet stalls all) | No (independent streams) |
| Header compression | No | HPACK | QPACK |
| Handshake round trips | TCP + TLS separately | TCP + TLS separately | Combined (fewer) |
| Encryption | Optional | Optional in spec, always TLS in practice | Mandatory |
| Connection migration | No | No | Yes |
| Negotiated via | (default) | ALPN (h2) |
Alt-Svc header |
What this means for you as a backend engineer
- Your handler code is version-agnostic — write it once.
- Version is usually decided by your reverse proxy / load balancer / CDN (module 00), which terminates HTTP/2 or /3 from the client and often talks HTTP/1.1 to your app internally. So your app frequently sees HTTP/1.1 even when users get HTTP/2 or /3.
- The old "bundle everything / few requests" front-end dogma is an HTTP/1.1 artifact; under HTTP/2/3 many small cacheable files (module 06) can beat one big bundle.
- On lossy/mobile networks, HTTP/3 is a real, measurable win because of independent streams and connection migration.
Command reference
| Command | What it does |
|---|---|
curl -v --http1.1 URL |
Force HTTP/1.1; see the text framing |
curl -v --http2 URL |
Prefer HTTP/2 (needs TLS + a supporting server) |
curl -v --http3 URL |
Try HTTP/3 (needs an HTTP/3-capable curl + server) |
curl -sI URL | grep -i alt-svc |
See if the server advertises HTTP/3 via Alt-Svc |
curl -w '%{http_version}\n' -o /dev/null -s URL |
Print which version was actually used |
Notes:
--http2/--http3are preferences; the server may not support them, and curl falls back. Check the actual version with-w '%{http_version}'.- Over plain
http://(no TLS) you'll get HTTP/1.1 — browsers only do HTTP/2 over TLS. Localuvicornspeaks HTTP/1.1 by default; to see HTTP/2/3 you generally test against a public site or put a proxy in front. Alt-Svcin a response is the breadcrumb that a server also offers HTTP/3.
Hands-on exercises
You mostly test against real public servers here (they support HTTP/2/3);
local uvicorn is HTTP/1.1.
1. Force HTTP/1.1 and read the text framing
curl -v --http1.1 https://www.cloudflare.com/ 2>&1 | grep -E '^[<>]' | head -20
Expected: > GET / HTTP/1.1 and < HTTP/1.1 200 — the classic text
protocol lines you learned in module 01. This is the human-readable wire
format.
2. See HTTP/2 in action
curl -v --http2 https://www.cloudflare.com/ 2>&1 | grep -iE 'HTTP/2|ALPN|using http' | head
curl -w 'negotiated: HTTP/%{http_version}\n' -o /dev/null -s https://www.cloudflare.com/
Expected: ALPN offering h2, lines like using HTTP/2, and the version
readout showing 2. Note the request lines look lowercased/pseudo-header
style (:method, :path) — that's the binary protocol decoded for you.
3. Discover HTTP/3 availability
curl -sI https://www.cloudflare.com/ | grep -i alt-svc
Expected: an alt-svc: h3=":443"; ... header — the server advertising
HTTP/3. That header is how a browser learns to try QUIC next time.
4. Try HTTP/3 (if your curl supports it)
curl --http3 -w 'used: HTTP/%{http_version}\n' -o /dev/null -s https://www.cloudflare.com/ 2>&1
Expected: if your curl was built with HTTP/3 support, used: HTTP/3;
otherwise an error saying HTTP/3 isn't supported (curl --version lists
features — look for HTTP3). Either outcome teaches you HTTP/3 needs
explicit client support and UDP reachability.
5. Compare which version different sites negotiate
for host in https://www.google.com https://example.com https://www.cloudflare.com; do
v=$(curl -w '%{http_version}' -o /dev/null -s "$host")
echo "$host -> HTTP/$v"
done
Expected: a mix — most big sites negotiate HTTP/2 (or /3), while
example.com may be HTTP/1.1 or /2. This shows negotiation is per-server.
6. See that semantics are identical across versions
curl -s --http1.1 -o /dev/null -w 'h1: code=%{http_code} type=%{content_type}\n' https://example.com/
curl -s --http2 -o /dev/null -w 'h2: code=%{http_code} type=%{content_type}\n' https://example.com/
Expected: the same status code and content type regardless of version. The meaning is version-independent; only the framing differed. This is the module's core point, demonstrated.
7. Your local app is HTTP/1.1
# with `uvicorn app:app` running from an earlier module
curl -w 'local: HTTP/%{http_version}\n' -o /dev/null -s http://127.0.0.1:8000/
Expected: HTTP/1.1. Plain http:// and a default uvicorn = HTTP/1.1.
This is why "my app sees HTTP/1.1 even though users get HTTP/2" — a proxy
in front handles the newer version and speaks 1.1 to your app.
8. Diagnose and fix: forcing a version that isn't offered
curl -v --http2-prior-knowledge http://example.com/ 2>&1 | head -15
Expected: an error or failure — --http2-prior-knowledge tells curl to
assume HTTP/2 with no negotiation over cleartext, but the server
expects HTTP/1.1 there and the connection breaks. Diagnose: you forced
a wire format the server wasn't offering, over a scheme (plain http) where
browsers don't do HTTP/2 anyway. Fix: let negotiation happen — use
--http2 (a preference, with fallback) over https://, e.g.
curl -v --http2 https://example.com/. Lesson: versions are negotiated;
forcing one the peer doesn't offer breaks the connection.
Independent challenge
No code given.
Task: Produce a short written comparison, backed by evidence you
gather yourself, of how a real high-traffic site serves its homepage:
determine (a) which HTTP version your client actually negotiates with it,
(b) whether it advertises HTTP/3, and (c) at least two concrete wire-level
differences you can observe between an HTTP/1.1 and an HTTP/2 fetch of
the same URL (hint: look at how request headers appear, and whether
multiple resources could share one connection). Then explain, in terms of
this module's mechanisms, why the old front-end practice of bundling all
JavaScript into one file (which module 06's caching concepts also touch)
became unnecessary — even counterproductive — once that site moved to
HTTP/2. You'll reuse the curl -w '%{http_version}' and Alt-Svc
techniques from the command reference.
Hint
For (c), fetch the same URL with --http1.1 and then --http2 under
curl -v and compare the >/< lines: HTTP/2 shows pseudo-headers
(:method, :path, :authority) and lowercased header names, and its
multiplexing means many resources ride one connection. The bundling point:
under HTTP/1.1's one-request-at-a-time-per-connection limit, fewer files =
fewer serialized round trips; under HTTP/2 multiplexing, many small files
download concurrently on one connection and each can be cached and
invalidated independently (module 06), so a one-line change doesn't bust
the whole bundle's cache.
Common mistakes & troubleshooting
- Thinking a new HTTP version changes your API's behavior. Semantics are identical; only framing/transport change. Your handler code is version-agnostic.
- Expecting HTTP/2 over plaintext in browsers. Browsers only do
HTTP/2 over TLS. Plain
http://gets HTTP/1.1. - Assuming multiplexing removes all head-of-line blocking in HTTP/2. It removes it at the HTTP layer but not at the TCP layer — one lost packet still stalls all streams. Only HTTP/3 (QUIC) fixes that.
- Still bundling aggressively "for performance" under HTTP/2/3. The many-small-requests penalty is largely gone; huge bundles hurt caching.
- Relying on HTTP/2 Server Push. It's deprecated/removed in practice.
- Forgetting your app usually sees HTTP/1.1 behind a proxy. The proxy terminates HTTP/2/3 from users and talks 1.1 to your app. Don't debug version behavior in your app expecting the client's version.
- Assuming HTTP/3 always works. UDP can be blocked; clients fall back to HTTP/2. It's an optimization, not a guarantee.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What stays the same across HTTP/1.1, /2, and /3, and what changes?
- What is HTTP/1.1's core performance limitation on a single connection, and what two hacks did front-end developers use to work around it?
- What does HTTP/2 multiplexing fix, and what head-of-line blocking does it not fix — and why?
- How does HTTP/3 eliminate the head-of-line blocking that still affected HTTP/2? What transport does it use?
- How is the version chosen between client and server for HTTP/2 vs. HTTP/3?
- Your users get HTTP/2 but your FastAPI app logs show HTTP/1.1. Explain.
- Why did "bundle all your JS into one file" stop being good advice under HTTP/2?
Answers
- HTTP semantics (methods, headers, status codes, bodies) stay identical. What changes is the wire format (text vs. binary frames), multiplexing, and transport (TCP vs. QUIC/UDP) — purely performance.
- On one connection, requests are answered serially (one at a time), causing request-level head-of-line blocking. Workarounds: open ~6 parallel TCP connections per origin, and reduce request count by bundling JS/spriting images/inlining assets.
- Multiplexing lets many requests/responses interleave over one connection as independent streams, fixing HTTP-level HOL blocking. It does not fix TCP-level HOL blocking: because it's one TCP connection and TCP delivers bytes in order, a single lost packet stalls all streams until retransmission.
- HTTP/3 runs over QUIC (on UDP), which understands streams at the transport layer, so a lost packet only stalls its own stream, not the others — eliminating TCP-level HOL blocking.
- HTTP/1.1 vs HTTP/2 is chosen during the TLS handshake via ALPN (client
offers
h2/http/1.1, server picks). HTTP/3 is advertised via theAlt-Svcresponse header and tried on a later connection over UDP. - A reverse proxy/load balancer/CDN terminates HTTP/2 from the user and forwards the request to your app over HTTP/1.1 internally, so the app only ever sees HTTP/1.1.
- HTTP/2 multiplexes many requests concurrently over one connection, so many small files download in parallel without the old per-request penalty — and keeping them separate means one file changing doesn't invalidate the whole bundle's cache (module 06).
Further reading & sources
- MDN: Evolution of HTTP - a readable history of the 0.9 → 1.1 → 2 → 3 progression and why each changed.
- RFC 9113: HTTP/2 - the authoritative spec for binary framing, streams, and HPACK.
- RFC 9114: HTTP/3 and RFC 9000: QUIC - the HTTP/3 mapping and the QUIC transport it rides on.
- Cloudflare: HTTP/3 vs HTTP/2 - a clear explainer of QUIC, HOL blocking, and connection migration.
- caniuse: HTTP/3 and HTTP/2 - real-world browser and version support you can cite.
- MDN: Alt-Svc - the header that advertises HTTP/3 availability, from exercise 3.
Free PDF notes
Download these notes as a printable PDF, with every diagram and interview questions: SD-B-013: HTTP/1.1 vs HTTP/2 vs HTTP/3. Each section in one file: Phase 2, Networking (PDF).
Next
08-content-negotiation-and-compression — now that you understand connections and framing, we look at how client and server agree on format and encoding: content negotiation and compression.