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-Svc let 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:

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-Svc advertisement 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 to line).

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 says HTTP/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 h2 via ALPN. The server advertises "h3 over UDP is available too" via Alt-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 h2 means 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 -k is 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, --http3 won't work. In that case, verify its existence via Alt-Svc and 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

  1. What is an HTTP/2 stream? How does it differ from HTTP/1.1's "one request per connection"?
  2. Even with HTTP/2 multiplexing, head-of-line blocking remains — at which layer? How does QUIC solve it?
  3. Which transport (TCP / UDP) does QUIC run on? Why UDP?
  4. What does the Alt-Svc: h3 header convey? How does a client use it?
  5. Do HTTP's semantics (method / status / headers) change between HTTP/2 and HTTP/3?
  6. In a packet capture, how can you tell HTTP/2 and HTTP/3 packets apart?

References

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:2 plus iproute2 (serves h1/h2/h3 on :443 with 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 :443 site. The Caddyfile site was changed from :443 to www.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 via curl --resolve www.example.lab:443:10.0.0.2 (both examples/quic-11/run.sh and the lab's steps were updated).
  • Measuring the multiplexing. --parallel-immediate was 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.

Read more