HTTP/2 Streams and the Jump to QUIC: Why HTTP/3 Ditched TCP
This post is part of Protocol Lab, a free, hands-on series for learning networking protocols by building and breaking them in a container lab. All the lab material — topologies, configs, and scripts — lives in the repo: github.com/pathvector-studio/protocol-lab.
HTTP/1.1 (Lab 10) handles one request at a time per connection. HTTP/2 multiplexes many requests as streams over a single TCP connection. HTTP/3 moves those streams onto QUIC, which runs over UDP. This lab makes that progression visible: fetch over HTTP/2 and confirm ALPN negotiated h2, fire several requests at once and watch them share one TCP connection, read the Alt-Svc: h3 header the server uses to advertise HTTP/3, and confirm that HTTP/3 / QUIC lives on UDP, not TCP.
Reading guide: rfc-notes/http2-quic-streams.md
Prerequisites: HTTP Lab 10: One Exchange, Read in the Clear, TLS Lab 09: What Is Visible Before Encryption
Expected time: 55–70 minutes.
The Goal
By the end, you should be able to explain this comparison:
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| Transport | TCP | TCP | QUIC over UDP |
| Concurrency | 1 request / connection | many streams / 1 connection | many streams / 1 connection |
| Head-of-line blocking | at HTTP layer | at TCP layer | avoided per-stream |
| Advertised by | — | ALPN h2 |
Alt-Svc: h3 + ALPN h3 |
What You Will Learn
- What a stream is, and how HTTP/2 multiplexes streams over one connection.
- Why one TCP connection can still stall all streams (TCP head-of-line blocking).
- How QUIC puts streams directly on UDP and avoids that cross-stream stall.
- How ALPN and
Alt-Svclet a client discover and pick a protocol version.
This lab does not cover:
- HTTP/2 flow control, priorities, or HPACK header compression internals.
- QUIC's congestion control or loss recovery details.
- 0-RTT / connection migration.
- Tuning for performance.
Where to Read in the RFCs
| RFC | Section | What to focus on |
|---|---|---|
| RFC 9113 | 5 | HTTP/2 streams (states, multiplexing) |
| RFC 9113 | 4 | Frames (the units that carry streams in pieces) |
| RFC 9114 | 2 | HTTP/3 stream mapping (how it maps onto QUIC streams) |
| RFC 9000 | 2 | QUIC streams (independent delivery) |
| RFC 7301 | 3 | ALPN (choosing between h2 / h3) |
| RFC 9110 | 3.9 | The idea behind Alt-Svc (advertising an alternative service) |
The Big Picture
Two nodes, as in Lab 07. The server is Caddy, which serves HTTP/1.1, HTTP/2, and HTTP/3 from a single binary. The client is netshoot's curl.
client (10.0.0.1) ------ eth1/eth1 ------ server (10.0.0.2:443 tcp+udp)
Caddy
h1 / h2 (TCP) / h3 (QUIC/UDP)
The server's response body echoes back whichever protocol handled the request (Hello over HTTP/2.0 ...), so the body itself tells you which transport the request arrived on.
flowchart TB
subgraph h2["HTTP/2 over TCP"]
t["1 TCP connection"] --- s1["stream 1"]
t --- s3["stream 3"]
t --- s5["stream 5"]
note2["1つの TCP が詰まると<br/>全 stream が待つ<br/>(TCP head-of-line blocking)"]
end
subgraph h3["HTTP/3 over QUIC/UDP"]
q["1 QUIC connection (UDP)"] --- q1["stream 0"]
q --- q2["stream 4"]
q --- q3["stream 8"]
note3["stream ごとに独立配送<br/>1つの loss が他を止めない"]
end
(In the diagram: when the single TCP connection stalls, all streams wait — TCP head-of-line blocking. With QUIC, each stream is delivered independently, so one loss doesn't stop the others.)
The server image is built by run.sh from examples/quic-11/Dockerfile (caddy:2 plus iproute2). The client is nicolaka/netshoot.
What You Need
Recommended environment:
- Linux / WSL2 / a Linux VM
- Docker
- containerlab
Images used:
protocol-lab/caddy:2(built locally fromexamples/quic-11/Dockerfile)nicolaka/netshoot:latest
Note: To observe HTTP/3 from the client, its curl must be built with HTTP/3 support. Even without it, you can still confirm QUIC exists via the
Alt-Svcadvertisement and the server's UDP listener.
Running the Lab
The quick path, which builds the Caddy image, deploys, captures the HTTP/2 fetch and multiplexing, checks Alt-Svc, and tears down for you:
./scripts/labctl.sh run quic-11
Or step through it manually:
1. Move into the working directory
cd protocol-lab/examples/quic-11
2. Build and deploy
docker build -t protocol-lab/caddy:2 .
sudo containerlab deploy -t quic-11.clab.yml
Caddy's internal CA issues certificates for a named site (www.example.lab). So instead of hitting the raw IP, the client pins the name to the IP with --resolve www.example.lab:443:10.0.0.2 and connects with that name (as the SNI). Every command below carries this --resolve flag.
3. Do a single HTTP/2 fetch
docker exec clab-quic-11-client curl -k --resolve www.example.lab:443:10.0.0.2 --http2 -sv https://www.example.lab/one
What to look for:
* ALPN: server accepted h2
* using HTTP/2
< HTTP/2 200
Hello over HTTP/2.0 for /one
-k accepts the self-signed certificate (Caddy's internal CA). The body echoes back HTTP/2.0.
4. Fire several requests at once and watch the multiplexing
docker exec clab-quic-11-client sh -c \
"curl -k --resolve www.example.lab:443:10.0.0.2 --http2 -sv --parallel \
https://www.example.lab/a https://www.example.lab/b https://www.example.lab/c 2>&1 | grep -Ei 'multiplex|Connected|HTTP/2'"
Use plain --parallel (without --parallel-immediate): curl then establishes the first connection and multiplexes the rest onto it. With --parallel-immediate, curl opens separate connections right away and you won't see the multiplexing.
What to look for:
- All three requests ride the same connection (
Multiplexed connection found). - There's only one connection (the same
Connected toline).
Counting TCP connections in the capture confirms it: three requests, but only one SYN (= multiplexed onto one connection).
docker exec clab-quic-11-client sh -c \
"tcpdump -n -r /tmp/quic-11.pcap 'tcp[tcpflags] & tcp-syn != 0 and tcp[tcpflags] & tcp-ack == 0' | wc -l"
5. Read the HTTP/3 advertisement
docker exec clab-quic-11-client sh -c "curl -k --resolve www.example.lab:443:10.0.0.2 --http2 -sD - -o /dev/null https://www.example.lab/"
In the response headers:
alt-svc: h3=":443"; ma=2592000
This is an advertisement: "the same service is also available over h3 (HTTP/3) on :443 (UDP)." The client can try QUIC on its next visit.
6. Try HTTP/3 (if your curl supports it)
docker exec clab-quic-11-client sh -c "curl -V | grep -i HTTP3"
docker exec clab-quic-11-client curl -k --resolve www.example.lab:443:10.0.0.2 --http3 -sv https://www.example.lab/three
If supported, the body reads Hello over HTTP/3.0 ..., and the capture shows UDP/443 packets (QUIC) instead of TCP. If not supported, confirm QUIC is listening on the server side instead:
docker exec clab-quic-11-server sh -c "ss -uln"
Expected Output
- HTTP/2 fetch:
using HTTP/2, body saysHTTP/2.0. - Multiplexing: one SYN for three requests (one TCP connection), multiple stream IDs.
alt-svc: h3=":443".- (If supported) HTTP/3 fetch: body says
HTTP/3.0, capture shows UDP.
Why It Works
HTTP/2 and HTTP/3 aim at the same thing: flow many exchanges concurrently over one connection. The difference is what the streams ride on.
- HTTP/2 = streams over TCP. Inside a single TCP connection, data is chopped into frames and bundled by stream number. Multiplexing removes HTTP/1.1's "one request per connection" constraint. But underneath is still one TCP connection: if a packet is lost and waiting on retransmission, every stream behind it stalls at the TCP level (TCP head-of-line blocking).
- HTTP/3 = streams over QUIC (UDP). QUIC implements encryption, reliability, and streams itself, on top of UDP. Streams are delivered independently, so a loss on one stream doesn't stall the others — a real advantage on lossy paths.
- Discovery. The client arrives over TCP first and selects
h2via ALPN. The server advertises "h3 over UDP is available too" viaAlt-Svc: h3. The client tries QUIC next time.
The key insight: HTTP's semantics (method / status / headers — Lab 10) stay the same; only the carriage changes (transport and multiplexing).
Common Pitfalls
- TLS is effectively mandatory for HTTP/2. Browsers and curl assume
h2means TLS + ALPN. That's why Lab 09's TLS work is the foundation here. - Confusing multiplexing with parallel connections. HTTP/1.1 parallelized with multiple TCP connections. HTTP/2 multiplexes streams inside one connection.
- TCP head-of-line blocking. HTTP/2 removed HoL blocking at the HTTP layer, but the TCP-layer HoL remains. QUIC is what solves that.
- QUIC is its own transport on top of UDP. It's not just raw UDP sends — it brings its own encryption, ordering, retransmission, and streams.
- Why
-kis needed. Caddy's internal CA isn't in the client's trust store. In production you'd use a public CA. - Observing HTTP/3 depends on the client. If curl isn't built with HTTP/3,
--http3won't work. In that case, verify its existence viaAlt-Svcand the UDP listener.
Cleanup
sudo containerlab destroy -t quic-11.clab.yml --cleanup
If you used labctl.sh run quic-11, the script runs destroy for you at the end.
Check Your Understanding
- What is an HTTP/2 stream? How does it differ from HTTP/1.1's "one request per connection"?
- Even with HTTP/2 multiplexing, head-of-line blocking remains — at which layer? How does QUIC solve it?
- Which transport (TCP / UDP) does QUIC run on? Why UDP?
- What does the
Alt-Svc: h3header convey? How does a client use it? - Do HTTP's semantics (method / status / headers) change between HTTP/2 and HTTP/3?
- In a packet capture, how can you tell HTTP/2 and HTTP/3 packets apart?
References
- RFC 9113: HTTP/2
- RFC 9114: HTTP/3
- RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport
- RFC 7301: TLS Application-Layer Protocol Negotiation (ALPN)
- RFC 9110: HTTP Semantics (Alt-Svc)
- Caddy web server
Verified Run Log (2026-07-05)
This lab has been confirmed reproducible on real hardware.
Environment:
- Ubuntu 26.04 LTS (kernel 7.0.0-27-generic, x86_64)
- Docker 29.1.3
- containerlab 0.77.0
- server:
protocol-lab/caddy:2, i.e.caddy:2plus iproute2 (serves h1/h2/h3 on:443with an internal CA) - client:
nicolaka/netshoot:latest(curl 8.21.0 / nghttp2 1.69.0, built without HTTP/3 support)
Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run quic-11 performed deploy → verify → destroy, and verification.json returned "status": "verified".
Environment drift / design fixes this verification required
- Caddy's internal CA cannot issue a certificate for a bare
:443site. The Caddyfile site was changed from:443towww.example.lab:443, giving the certificate a subject name. The host part only affects SNI/Host matching — the bind stays on the wildcard:443, so nothing depends on which lab IP gets assigned. The client connects by name viacurl --resolve www.example.lab:443:10.0.0.2(bothexamples/quic-11/run.shand the lab's steps were updated). - Measuring the multiplexing.
--parallel-immediatewas dropped in favor of plain--parallel, so all three requests ride one TCP connection, and the capture is stopped right after the multiplexed fetch so later fetches aren't counted.
A single HTTP/2 fetch (ALPN h2 over TLS)
$ curl -k --resolve www.example.lab:443:10.0.0.2 --http2 -sv https://www.example.lab/one
* ALPN: server accepted h2
* issuer: CN=Caddy Local Authority - ECC Intermediate
* using HTTP/2
< HTTP/2 200
Hello over HTTP/2.0 for /one
The body self-reports the protocol that handled it: Hello over HTTP/2.0. The certificate is issued by Caddy's internal CA.
Three requests multiplexed onto one connection
$ curl -k --resolve www.example.lab:443:10.0.0.2 --http2 -sv --parallel \
https://www.example.lab/a https://www.example.lab/b https://www.example.lab/c
* Waiting on connection to negotiate possible multiplexing.
* Multiplexed connection found
< HTTP/2 200
< HTTP/2 200
< HTTP/2 200
Hello over HTTP/2.0 for /a
Hello over HTTP/2.0 for /b
Hello over HTTP/2.0 for /c
Counting SYNs (new TCP connections) during the capture:
[protocol-lab][quic-11] distinct TCP connections opened during the multiplexed fetch: 1 (1 = fully multiplexed)
Three requests, one new TCP connection. HTTP/2 multiplexes multiple streams over a single connection.
The HTTP/3 advertisement (Alt-Svc) and the QUIC listener
$ curl -k --resolve www.example.lab:443:10.0.0.2 --http2 -sD - -o /dev/null https://www.example.lab/
HTTP/2 200
alt-svc: h3=":443"; ma=2592000
alt-svc: h3=":443" advertises that the same service is available over h3 (HTTP/3, UDP :443).
This host's curl 8.21.0 was built without HTTP/3 (curl -V shows no HTTP3), so instead of an h3 fetch from the client, the QUIC listener was confirmed on the server side:
$ docker exec clab-quic-11-server ss -uln
State Recv-Q Send-Q Local Address:Port Peer Address:Port
UNCONN 0 0 *:443 *:*
The server is listening for QUIC on UDP :443 — the h3 endpoint exists. With an HTTP/3-capable curl, curl --http3 would show Hello over HTTP/3.0 riding UDP/443.
Cleanup
containerlab destroy -t quic-11.clab.yml --cleanup
That's the jump from HTTP/2 to HTTP/3 in a nutshell: the streams stay, the semantics stay, but moving them from TCP onto QUIC over UDP is what finally kills cross-stream head-of-line blocking.
Explore the full Protocol Lab series here: github.com/pathvector-studio/protocol-lab. If these labs are useful to you, please ⭐ star the repo on GitHub — it genuinely helps others find the project.
Next up, we'll dig deeper into QUIC itself — the handshake, connection IDs, and what "encrypted transport" really buys you.