Networking for Developers

HTTP/1.1, HTTP/2 and HTTP/3

Requests and responses on the wire, keep-alive, head-of-line blocking, multiplexing, QUIC, and what changes for you as a developer.

Lesson 0415 min read#networking#http#http2#http3#quic

On this page

  1. Words used in this lesson
  2. HTTP is just text over TCP
  3. Keep-alive: one connection, many requests
  4. The real limit of HTTP/1.1: one answer at a time
  5. HTTP/2: many requests on one connection
  6. The remaining problem: TCP head-of-line blocking
  7. HTTP/3 and QUIC: leaving TCP behind
  8. What changes for you as a developer
  9. Try it
  10. Interview questions
  11. Key takeaways
  12. What's next

In one line: HTTP is a text conversation (a request, then a response) that has been carried over three different kinds of pipe. HTTP/1.1 sends one answer at a time per connection, so browsers open many connections. HTTP/2 puts many requests on one connection as small tagged pieces. HTTP/3 does the same on top of QUIC (which runs on UDP), so one lost packet no longer stalls everything. The meaning of the request (method, URL, headers, status codes) stays the same in all three.

You have used HTTP every day, but mostly through a library. fetch("/api/users") hides everything. This lesson opens the pipe. We will type raw HTTP by hand with nc (netcat), watch one TCP connection serve many requests, see why one slow response can block the ones behind it, and then run the same requests over HTTP/2 and HTTP/3 and compare. Every number in this lesson is from a real run on my machine, and I tell you where I could not run something.

This is lesson 4 of a nine-lesson series. The previous lesson was DNS, which turns a name into an IP address. Before that, TCP and UDP explained connections, the three-way handshake and packet loss. You need those ideas here, because the whole story of HTTP versions is the story of how to use a TCP connection better, and finally how to stop depending on TCP. The next lesson, TLS and certificates, explains the lock in "HTTPS". We used TLS in a few places below because browsers only speak HTTP/2 and HTTP/3 over TLS, but we leave the details for that lesson. Later, load balancers and proxies and WebSockets and real-time build on this lesson.

What I ran / checked. Linux, Node.js v22.22.0, curl 8.5.0 (built with nghttp2, so HTTP/2 works), OpenBSD netcat 1.226, OpenSSL 3.0.13, tcpdump 4.99.4, and for HTTP/3 the Python library aioquic 1.7.0 (installed with pip; I fetched its example http3_server.py and http3_client.py from GitHub). All servers were local toy servers on 127.0.0.1. Output is pasted from real runs; dates, ports and timings change every run. Sources I read (in my own words): the Node.js http2 API documentation (raw markdown from the nodejs/node repository, only the parts about createSecureServer, allowHTTP1, and unknown protocols) and the source of the aioquic examples. Could not reach or did not read: the HTTP and QUIC RFCs (RFC 9110, 9112, 9113, 9114, 9000) were not fetched in this session, so statements about the protocols that I did not observe come from my own background knowledge and I say so. Could not run: curl --http3, because this curl build has no HTTP/3 support (curl -V lists HTTP2 but not HTTP3). I could not make real packet loss either: the kernel on this sandbox has no netem (the Linux tool for adding fake loss and delay), so the head-of-line blocking experiment uses a small proxy that holds bytes back, and I say clearly what that proves and what it does not.

Words used in this lesson

  • HTTP is the request and response language of the web. A client sends a request, a server sends back a response.
  • HTTP/1.1 is the text-based version from 1997 (updated several times since). Still very common.
  • HTTP/2 is the binary version, with many requests sharing one connection.
  • HTTP/3 is HTTP carried over QUIC instead of TCP.
  • QUIC is a transport protocol built on UDP. It does its own reliability, its own streams and includes TLS 1.3.
  • Keep-alive (persistent connection) means reusing one TCP connection for several requests instead of opening a new one each time.
  • Head-of-line (HOL) blocking means something at the front of a queue is stuck and everything behind it must wait.
  • Pipelining means sending several HTTP/1.1 requests without waiting for the answers. Answers still come back in order.
  • Multiplexing means mixing many independent conversations on one connection.
  • Stream is one request and response pair inside an HTTP/2 or HTTP/3 connection, with its own number.
  • Frame is a small chunk with a header (a length, a type, a stream number) and a payload. HTTP/2 sends everything as frames.
  • HPACK is the header compression scheme of HTTP/2. (HTTP/3 uses a cousin called QPACK.)
  • ALPN is a small TLS field where client and server agree which protocol to speak (h2, http/1.1, h3).
  • h2c is HTTP/2 without TLS (cleartext). Browsers do not use it.
  • Chunked transfer encoding is a way in HTTP/1.1 to send a body whose length is not known yet, in pieces.
  • Alt-Svc is a response header that says "this site is also available over another protocol or port", used to move a client to HTTP/3.
  • 0-RTT means sending data in the very first flight to a server you have talked to before, to save a round trip.

HTTP is just text over TCP

Before the versions, one fact that makes everything else simple: HTTP/1.1 is plain text sent through a TCP connection. There is no magic. You can speak it with a tool that only copies bytes. I started a tiny Node server on port 8080 (code at the end of this section) and typed a request with nc:

TEXT
$ printf 'GET /hello HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8080HTTP/1.1 200 OKContent-Type: text/plainDate: Sat, 17 Oct 2026 13:29:56 GMTConnection: closeContent-Length: 38you asked for /hello on connection #1

Let us read what happened. printf writes the bytes of the request and nc sends them down a TCP connection to port 8080. The server wrote bytes back, and nc printed them. Four parts:

  1. Request line: GET /hello HTTP/1.1. Method, path, version.
  2. Headers: one Name: value per line. Host is required in HTTP/1.1. It lets one server (one IP address) host many sites, which you saw as virtual hosting in the DNS lesson.
  3. A blank line. Each line ends with \r\n (carriage return and line feed, two bytes). The empty line means "headers are finished".
  4. Body (optional). Here a GET has none.

The response has the same shape: a status line (HTTP/1.1 200 OK), headers, a blank line, then the body. Content-Length: 38 says how many bytes of body to read. Without a length the client would not know when the body ends.

The server I used for all the HTTP/1.1 tests is only this:

JavaScript
// h1.mjsimport http from "node:http";let conns = 0;const s = http.createServer((req, res) => {  const delay = req.url.startsWith("/slow") ? 300 : 0;   // /slow... waits 300 ms  setTimeout(() => {    res.setHeader("Content-Type", "text/plain");    res.end(`you asked for ${req.url} on connection #${req.socket.id}\n`);  }, delay);});s.on("connection", (sock) => {  sock.id = ++conns;  console.log(`[server] new TCP connection #${sock.id}`);  sock.on("close", () => console.log(`[server] connection #${sock.id} closed`));});s.listen(8080, "127.0.0.1");

The connection event fires once per TCP connection. The response text includes the connection number, so we can always see which TCP connection carried which request. That is the whole trick behind the experiments below.

What if the Host header is missing?

HTTP/1.1 requires it. Node's server follows the rule:

TEXT
$ printf 'GET /x HTTP/1.1\r\n\r\n' | nc 127.0.0.1 8080 | head -5HTTP/1.1 400 Bad RequestConnection: closeDate: Sat, 17 Oct 2026 13:29:56 GMTTransfer-Encoding: chunked

A 400 Bad Request and the connection is closed. If you ever send hand-made requests and get a 400 for no clear reason, check the Host line and the \r\n endings first. (Typing in a terminal with only \n often fails on strict servers.)

The body length problem: Content-Length and chunked

The receiver must know where one message ends, because on a TCP stream there are no message boundaries (see the TCP lesson). HTTP/1.1 has two ways. One is Content-Length. The other is chunked encoding, used when the server does not know the size yet, for example when it streams. I made a server that writes hello , waits 200 ms, then writes world:

TEXT
$ printf 'GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8082 | cat -AHTTP/1.1 200 OK^M$Date: Sat, 17 Oct 2026 13:33:05 GMT^M$Connection: close^M$Transfer-Encoding: chunked^M$^M$6^M$hello ^M$6^M$world$^M$0^M$^M$

cat -A shows ^M for each carriage return and $ for each line end, so you can see the real bytes. Each chunk starts with its size in hexadecimal (6 for six bytes), then the data, then a line break. A chunk of size 0 ends the body. (The second 6 counts world plus its newline.) This is how a server can send a response it is still producing. Remember this, it comes back in the SSE section of the real-time lesson.

Keep-alive: one connection, many requests

A TCP connection costs a round trip to open (the three-way handshake), and with TLS it costs more. Opening a new one for every image would be slow. In HTTP/1.0 that was exactly what happened. HTTP/1.1 changed the default: the connection stays open after the response, so the next request can reuse it. This is called a persistent connection, or keep-alive.

Look at what Node did in two of my nc runs. In the first I sent Connection: close, and the server answered with Connection: close too. In the second I sent two requests back to back on one connection (the second one with Connection: close to end the test):

TEXT
$ printf 'GET /one HTTP/1.1\r\nHost: localhost\r\n\r\nGET /two HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8080HTTP/1.1 200 OKContent-Type: text/plainDate: Sat, 17 Oct 2026 13:29:56 GMTConnection: keep-aliveKeep-Alive: timeout=5Content-Length: 36you asked for /one on connection #2HTTP/1.1 200 OKContent-Type: text/plainDate: Sat, 17 Oct 2026 13:29:56 GMTConnection: closeContent-Length: 36you asked for /two on connection #2

Both answers say connection #2: same TCP connection. The first answer says Connection: keep-alive and Keep-Alive: timeout=5, which means "I will wait 5 seconds for your next request and then hang up". I tested that too: a client that sent one request and then did nothing was disconnected like this:

TEXT
5ms got response, bytes: 1876013ms server closed idle connection

(About 5 seconds, plus a moment for the server's timer to fire. Node's default keepAliveTimeout is 5 seconds.)

The same thing happens with curl. Given two URLs on the same host, it opens the connection once and says so in the verbose output:

TEXT
$ curl -sv http://127.0.0.1:8080/a http://127.0.0.1:8080/b 2>&1 | grep -E "^\* (Connected|Re-using|Connection)|^> GET|you asked"* Connected to 127.0.0.1 (127.0.0.1) port 8080> GET /a HTTP/1.1* Connection #0 to host 127.0.0.1 left intactyou asked for /a on connection #5* Re-using existing connection with host 127.0.0.1> GET /b HTTP/1.1* Connection #0 to host 127.0.0.1 left intactyou asked for /b on connection #5

"Re-using existing connection" is keep-alive at work.

Trap: keep-alive and the idle timeout race. A client keeps an idle connection in a pool. The server closes it after its timeout. If the client sends a new request at the very moment the server hangs up, the request fails with ECONNRESET or "socket hang up", even though nothing is really wrong. This shows up as rare, random errors in Node services that call other services. The usual fix is to keep the client's idle timeout shorter than the server's (and to retry safe requests such as GET). A related fact: Node's server closes idle connections after 5 seconds by default, and load balancers often use 60 seconds. If a load balancer keeps a connection longer than your Node app does, you get these resets between them. Always check that the one behind has the longer timeout.

In Node, you must opt in to connection reuse

From the server side, keep-alive just works. From the client side, it depends on the tool. curl reuses a connection within one command. With Node's http module, you create an Agent with keepAlive: true to keep sockets in a pool. Node 22's built-in fetch (based on the undici library) pools connections for you. If you use http.get with the default agent in code that makes many requests to the same host, check what your version does, and set an agent on purpose. I used explicit agents in the experiments below so the number of connections is not a surprise.

The real limit of HTTP/1.1: one answer at a time

Reuse saves handshakes. But there is a catch. On one HTTP/1.1 connection, the server answers requests in the order they came. If request 1 is slow, request 2 must wait for it, even when request 2 is ready. This is head-of-line blocking at the HTTP level.

I proved it. I sent a slow request (300 ms) and then a fast request (instant) on the same connection, in one go (this is "pipelining"):

TEXT
$ # /slow1 then /fast, both written at once on one connection305ms you asked for /slow1 on connection #6306ms you asked for /fast on connection #6

The fast request was ready at once, but its answer arrived at 306 ms, one millisecond after the slow one. It waited. (Node's server queues the responses in order, which is what the HTTP/1.1 rules require.) Browsers never turned pipelining on by default, because some old servers and proxies mishandled it. In practice, HTTP/1.1 clients send one request at a time per connection and wait for its response.

So how does a browser load a page with 80 files? It opens several connections to the same host. Browsers have commonly used six per host. Developers added tricks around that: spreading assets over several host names ("sharding"), combining files into one big bundle, putting small icons into one image ("sprites"). Those tricks existed because of this limit.

I measured the cost with a Node client sending 12 requests of 300 ms each, changing only the maximum number of connections:

JavaScript
// hol1.mjsimport http from "node:http";for (const n of [1, 6, 12]) {  const agent = new http.Agent({ keepAlive: true, maxSockets: n });  const t0 = Date.now();  await Promise.all(Array.from({ length: 12 }, (_, i) => new Promise((ok) =>    http.get({ host: "127.0.0.1", port: 8080, path: "/slow" + i, agent }, (r) => { r.resume(); r.on("end", ok); }))));  console.log(`maxSockets=${n}: 12 requests took ${Date.now() - t0} ms`);  agent.destroy();}
TEXT
maxSockets=1: 12 requests took 3638 msmaxSockets=6: 12 requests took 609 msmaxSockets=12: 12 requests took 310 ms

One connection: 12 times 300 ms, queued one after the other (3.6 seconds). Six connections: two rounds of six (about 0.6 seconds). Twelve connections: one round. The work on the server is the same each time. Only the queueing changed. More connections fix the queue, but each connection costs a TCP handshake, a TLS handshake, memory on the server, and its own slow start (see congestion control in the TCP lesson). Six connections also compete with each other for the network, instead of sharing one well-managed congestion window.

A server doing real work is not like my setTimeout. If your backend does the same CPU work for each request, more connections will not make the server faster. The test only shows the queueing rule of HTTP/1.1 on a connection.

HTTP/2: many requests on one connection

HTTP/2 (published in 2015, now described by RFC 9113) keeps the same meaning (methods, status codes, headers) but changes how they travel. It is the biggest change from the developer's side in how it works, yet the smallest in how you write code. Three changes matter.

1. It is binary, not text. Everything is sent as frames. Each frame starts with a 9-byte header: 3 bytes for the length, 1 byte for the type (HEADERS, DATA, SETTINGS and so on), 1 byte of flags, and 4 bytes for the stream number. I can show this with nc. I started a plain HTTP/2 server without TLS (an "h2c" server), then sent it ordinary HTTP/1.1 text. It did not answer in text. It answered with bytes:

TEXT
$ printf 'GET / HTTP/1.1\r\nHost: x\r\n\r\n' | nc -w1 127.0.0.1 8081 | od -c | head -30000000  \0  \0  \0 004  \0  \0  \0  \0  \0  \0  \0  \b  \a  \0  \0  \00000020  \0  \0  \0  \0  \0  \0  \0  \0  \0 002

Read the first 9 bytes: 00 00 00 (length 0), 04 (type 4 = SETTINGS), 00 (flags), 00 00 00 00 (stream 0, meaning "the whole connection"). That is an empty SETTINGS frame, the first thing an HTTP/2 server sends. The following bytes are a GOAWAY frame (type 7), which says "I am closing, you did not speak HTTP/2". You cannot read this in a terminal. This is why HTTP/2 is hard to debug by eye and why you need tools such as curl -v or browser DevTools. (Frame layout and the meaning of types 4 and 7 are from my own knowledge of the protocol. The bytes are what the server really sent.)

A real HTTP/2 client begins with a fixed 24-byte "preface" (PRI * HTTP/2.0 and SM), which is deliberately not valid HTTP/1.1, so an old server fails fast instead of misreading it.

2. Many streams share one connection. Each request and its response get a stream number (client-started streams are odd: 1, 3, 5...). Frames from different streams are mixed on the wire, and the receiver sorts them by number. A slow response does not block a fast one, because the fast response's frames can go out in between. This is multiplexing.

I used curl --http2 --parallel on three slow URLs. All three took about the same time as one:

TEXT
$ curl -s --http2 --parallel --cacert c.pem https://localhost:8443/slow1 https://localhost:8443/slow2 https://localhost:8443/slow3 -w "%{http_version} %{time_total}\n"HTTP/2.0 /slow1 on connection #3HTTP/2.0 /slow2 on connection #3HTTP/2.0 /slow3 on connection #32 0.3067012 0.3064622 0.306450

Three requests of 300 ms each finished in about 306 ms, and the server log says all three arrived on connection #3. The 2 is the HTTP version curl used.

And with a Node HTTP/2 client sending the same 12 requests as before, using one connection:

JavaScript
// h2c.mjsimport http2 from "node:http2"; import fs from "node:fs";const client = http2.connect("https://localhost:8443", { ca: fs.readFileSync("c.pem") });client.on("connect", () => console.log("ALPN:", client.alpnProtocol));const t0 = Date.now();const one = (path) => new Promise((ok) => {  const req = client.request({ ":path": path });  let body = ""; req.setEncoding("utf8"); req.on("data", (d) => (body += d));  req.on("end", () => ok(body.trim())); req.end();});const out = await Promise.all(Array.from({ length: 12 }, (_, i) => one("/slow" + i)));console.log(`12 requests, ONE connection: ${Date.now() - t0} ms`);console.log(out.slice(0, 2).join("\n"));client.close();
TEXT
ALPN: h212 requests, ONE connection: 323 msHTTP/2.0 /slow0 on connection #6HTTP/2.0 /slow1 on connection #6

Compare the HTTP/1.1 numbers: 3638 ms with one connection, 609 ms with six, 310 ms with twelve. HTTP/2 got the "twelve connections" result using one connection. Notice :path in the code. HTTP/2 turns the request line into pseudo-headers that start with a colon: :method, :scheme, :authority (this replaces Host) and :path. curl -v printed them:

TEXT
* [HTTP/2] [1] OPENED stream for https://localhost:8443/hi* [HTTP/2] [1] [:method: GET]* [HTTP/2] [1] [:scheme: https]* [HTTP/2] [1] [:authority: localhost:8443]* [HTTP/2] [1] [:path: /hi]* [HTTP/2] [1] [user-agent: curl/8.5.0]* [HTTP/2] [1] [accept: */*]> GET /hi HTTP/2< HTTP/2 200< content-type: text/plain

The [1] is the stream number. Also note the header names are in lowercase. HTTP/2 requires lowercase. Any code that reads res.headers["Content-Type"] with a capital letter will break on HTTP/2 if it does not normalise the case.

3. Headers are compressed (HPACK). Real requests repeat the same large headers again and again: cookies, user-agent, accept. HTTP/1.1 sends them every time as text. HTTP/2 keeps a table of headers on both sides, and after the first time it sends just a tiny reference. I measured how many bytes the client sent for each of four identical requests carrying a 400-byte cookie, counted at a TCP proxy (this includes TLS overhead, so the first request also includes the handshake):

TEXT
HTTP/2   bytes client->server per request: 506, 42, 42, 42HTTP/1.1 bytes client->server per request: 2229, 548, 548, 548

After the first request, each HTTP/2 request cost 42 bytes against 548 bytes for HTTP/1.1. For a page with 100 requests and big cookies, that is a real difference. (A design reason for compression of this specific kind is that general-purpose compression of headers was found to leak secrets, the "CRIME" attack, so HPACK was designed to avoid that. I know this from background reading and did not test it here.)

How HTTP/2 starts: ALPN, not "upgrade"

How does a client know a server speaks HTTP/2? Browsers use HTTP/2 only with TLS, and the choice is made during the TLS handshake using ALPN, a field in which the client lists the protocols it can speak and the server picks one. curl -v showed it:

TEXT
* ALPN: curl offers h2,http/1.1* ALPN: server accepted h2* using HTTP/2

And openssl can ask the same question, as a check for any server:

TEXT
$ echo | openssl s_client -connect 127.0.0.1:8443 -alpn h2,http/1.1 2>/dev/null | grep -i alpnALPN protocol: h2

The server in this test used http2.createSecureServer with allowHTTP1: true, so one port serves HTTP/2 to clients that ask for it, and HTTP/1.1 to the rest. With curl --http1.1 against the same port:

TEXT
$ curl -s --http1.1 --cacert c.pem https://localhost:8443/hiHTTP/1.1 /hi on connection #h1

The Node documentation I read says that allowHTTP1 is what makes a secure HTTP/2 server fall back to HTTP/1.1 for clients that do not offer h2. The server code, in full:

JavaScript
// h2.mjsimport http2 from "node:http2"; import fs from "node:fs";const s = http2.createSecureServer(  { key: fs.readFileSync("k.pem"), cert: fs.readFileSync("c.pem"), allowHTTP1: true });s.on("request", (req, res) => {  const ms = req.url.startsWith("/slow") ? 300 : 0;  setTimeout(() => {    res.setHeader("content-type", "text/plain");    res.end(`HTTP/${req.httpVersion} ${req.url}\n`);  }, ms);});s.listen(8443, "127.0.0.1");

The request handler uses the same (req, res) shape as the http module. This is the "compatibility API" of Node's http2, so most Express-style code does not change. (Express itself needs a wrapper to run on http2; I did not test that.) The certificate here is a self-signed one made with openssl req -x509. How certificates work is the next lesson.

There is also h2c, HTTP/2 without TLS, used between your own services. curl --http2-prior-knowledge http://127.0.0.1:8081/ talks to such a server directly (it "knows" the server speaks HTTP/2). The old way to upgrade a plain HTTP/1.1 connection to HTTP/2 (Upgrade: h2c) is dropped in the current HTTP/2 RFC as I remember it, and servers often ignore it. I saw this: curl --http2 http://127.0.0.1:8080/ sent Connection: Upgrade, HTTP2-Settings and Upgrade: h2c, and my HTTP/1.1 server simply answered HTTP/1.1 200 OK and carried on. Nothing broke, but nothing was upgraded.

Trap: "we turned on HTTP/2 and it did not get faster." HTTP/2 helps when many resources load in parallel over a high-latency link. It does not make a slow database query faster, and it can even hurt on a very lossy network (next section). Measure with real pages on real networks before promising a speed-up. Also, tricks built for HTTP/1.1 (bundling everything into one file, sharding hosts) are less useful, and sharding can actually hurt HTTP/2, because it splits traffic over several connections and loses sharing.

The remaining problem: TCP head-of-line blocking

HTTP/2 fixed the HTTP-level queue. But all the streams now sit on one TCP connection. And TCP has its own rule that you met in lesson 2: it delivers bytes in order. If one packet is lost, TCP holds back all the bytes that arrived after it, until the lost packet is sent again and received. TCP does not know that those bytes belong to different streams. So a single lost packet can freeze all 12 streams, including the ones whose data had already arrived. This is TCP-level head-of-line blocking.

With HTTP/1.1 and six connections, a lost packet freezes only one of the six connections. HTTP/2 puts all its eggs in one basket.

I wanted to show this, but I could not create real packet loss (no netem in this sandbox). So I built a simulation that is honest about what it is: a tiny TCP proxy that, on the first connection only, holds back the bytes from the server for a window of time (from 100 ms to 600 ms after the connection starts). That is how TCP behaves when a packet is lost and later retransmitted: later bytes wait in order behind the missing piece. I then sent 6 requests of 300 ms each in two ways, through the proxy:

TEXT
HTTP/2 (1 connection):    per-request ms = [672, 673, 673, 673, 673, 673]HTTP/1.1 (6 connections): per-request ms = [364, 365, 368, 368, 369, 645]HTTP/2 (1 connection):    per-request ms = [679, 680, 680, 680, 680, 680]HTTP/1.1 (6 connections): per-request ms = [355, 356, 357, 358, 358, 640]

(Two runs, to show it repeats.) With HTTP/1.1, five of the six requests finished at about 360 ms. Only the request on the stalled connection was late (about 640 ms). With HTTP/2, all six were late, because they all share the stalled connection. Remember what this shows and what it does not show. It shows the mechanism: one stall on a shared in-order pipe delays every stream on it. It does not tell you how often real networks lose packets, and on good networks HTTP/2 is usually still the better choice. On a mobile network with a few percent loss, the effect is real, and it is the reason HTTP/3 exists.

HTTP/3 and QUIC: leaving TCP behind

You cannot fix TCP's in-order rule inside TCP, and you cannot easily replace TCP either, because routers, firewalls and middleboxes all over the internet understand TCP and may drop or change anything new that sits directly on IP. The trick the designers chose was to build a new transport on top of UDP, which these devices already let through. That transport is QUIC (RFC 9000), and HTTP carried over it is HTTP/3 (RFC 9114). What QUIC does:

  • Streams are built into the transport. Each stream has its own ordering. If a packet with stream 5's data is lost, only stream 5 waits. Streams 1, 3 and 7 keep going. This removes TCP head-of-line blocking.
  • TLS 1.3 is built in. The transport handshake and the encryption handshake happen together, so a new connection takes one round trip (TCP plus TLS 1.3 takes two, as we counted in the TLS lesson). If you have connected before, 0-RTT can send the request in the first flight, with a replay risk (a recorded 0-RTT request can be sent again by an attacker), so servers should only accept safe, repeatable requests this way.
  • Connection IDs, not address pairs. A TCP connection is identified by the four numbers (source IP, source port, destination IP, destination port). If your phone moves from Wi-Fi to mobile data, the IP changes and the TCP connection dies. A QUIC connection has an ID inside the packets, so it can survive the move. This is called connection migration.
  • Almost everything is encrypted, including most transport headers. This makes it harder for middleboxes to interfere, and also harder for you to debug with tcpdump alone.
  • It lives in user space. QUIC is a library inside the application (not in the operating system kernel like TCP), so it can be updated with the app. The cost is usually more CPU use than TCP for the same traffic, because TCP has had decades of hardware help.

Running HTTP/3 for real

My curl cannot do HTTP/3 (curl -V shows HTTP2 but not HTTP3 in its feature list), so I used the Python library aioquic. I wrote a tiny ASGI app that answers HTTP/<version> <path> after 300 ms for paths starting with /slow, started aioquic's example HTTP/3 server on UDP port 4433 using the same self-signed certificate, and fetched three slow URLs in one connection with its example client. One small note: this sandbox has no IPv6, and the example client opens an IPv6 socket, so I changed three lines in the installed library copy to use IPv4. That edit is only about the sandbox, not about HTTP/3.

TEXT
2026-10-17 13:32:57,190 INFO quic [c79b69a5ed264569] ALPN negotiated protocol h32026-10-17 13:32:57,496 INFO client Response received for GET /slow1 : 14 bytes in 0.3 s2026-10-17 13:32:57,496 INFO client Response received for GET /slow2 : 14 bytes in 0.3 s2026-10-17 13:32:57,496 INFO client Response received for GET /slow3 : 14 bytes in 0.3 s$ cat out/slow1HTTP/3 /slow1

ALPN negotiated protocol h3: the same ALPN idea as before, with a new name. Three slow requests ran side by side on one QUIC connection (all done at the same millisecond). While this ran, I captured the UDP traffic on the loopback interface with tcpdump:

TEXT
13:32:57.184840 IP 127.0.0.1.40737 > 127.0.0.1.4433: UDP, length 120013:32:57.187635 IP 127.0.0.1.4433 > 127.0.0.1.40737: UDP, length 120013:32:57.191623 IP 127.0.0.1.40737 > 127.0.0.1.4433: UDP, length 120013:32:57.191909 IP 127.0.0.1.40737 > 127.0.0.1.4433: UDP, length 6813:32:57.192033 IP 127.0.0.1.40737 > 127.0.0.1.4433: UDP, length 75

Everything is UDP. The very first packet from the client is exactly 1200 bytes, which is not an accident: QUIC requires the first client packet to be padded to at least 1200 bytes, so that a spoofed small packet cannot be used to make a server send a big reply to a victim (the "amplification" attack). The first byte of that packet was 0xcf and bytes 2 to 5 were 00 00 00 01, which is QUIC version 1; I only looked at those bytes, not the whole format. The handshake here took about 7 ms in total (13:32:57.1848 to .1920) on loopback, so I cannot show the round-trip saving from this test: on loopback there is no distance to save.

What I did not test: whether HTTP/3 really survives packet loss better in practice (I had no way to cause loss), 0-RTT, or connection migration. Those claims come from the design of QUIC as I know it, not from my runs.

How does a browser find out a site has HTTP/3?

A browser typing https://example.com does not know if the server offers HTTP/3 and UDP might be blocked. So the first connection normally uses HTTP/1.1 or HTTP/2 over TCP. The server's response can include a header:

TEXT
Alt-Svc: h3=":443"; ma=86400

meaning "this site is also available over HTTP/3 on UDP port 443, remember that for 86,400 seconds." The browser then tries QUIC on the next request and falls back to TCP if it fails. (There is also a newer way using DNS records of type HTTPS, which lets the browser know before the first connection.) I did not run the Alt-Svc flow in this lab, the header form is from memory. It is a useful thing to know because it explains two real problems. First, the first visit is never HTTP/3. Second, corporate firewalls often block UDP 443, and then the browser silently uses HTTP/2 over TCP. Your site should work well over both.

What changes for you as a developer

Mostly: very little in code, and some in how you think. A short list:

TopicHTTP/1.1HTTP/2HTTP/3
Formattextbinary framesbinary frames on QUIC
Connections per hostmany (about 6 in browsers)oneone
Parallel requestsonly across connectionsstreams on one connectionstreams on one connection
Head-of-line blockingHTTP level, per connectionTCP level, whole connectionper stream only
Header compressionnoneHPACKQPACK
TransportTCPTCPUDP (QUIC)
Encryptionoptional (TLS)practically always (browsers need TLS)always (TLS 1.3 inside QUIC)
Header namesany caselowercase onlylowercase only

Practical points:

  • You rarely choose the version in code. The browser and the server (or CDN) negotiate it. A common set-up is HTTP/3 or HTTP/2 between the user and a CDN or load balancer, then HTTP/1.1 from the load balancer to your Node app. That is fine. You can see which version a user got with req.httpVersion in Node, or the protocol field in your access log.
  • Do not read headers case-sensitively. Node lowercases them for you in req.headers. In other languages, use a case-insensitive lookup.
  • Connection, Keep-Alive, and Transfer-Encoding headers are banned in HTTP/2 (they are connection details of HTTP/1.1). A proxy that copies all headers blindly from an HTTP/1.1 response to an HTTP/2 response can break the connection. I know this rule from the protocol, I did not trigger the error here.
  • Do not stream "forever" responses without thinking about proxies. Chunked bodies and Server-Sent Events work on all three versions, but proxies in between may buffer them. This returns in the real-time lesson.
  • Server push is dead. HTTP/2 had a feature where a server sent files before the browser asked. It was hard to use well, and Chrome removed support for it, so don't build on it. (This is from my background knowledge. I did not re-check it.) Use 103 Early Hints or <link rel="preload"> instead.
  • Slow clients and limits. HTTP/2 servers set a limit on concurrent streams per connection (commonly around 100). A client that opens more waits. Settings are exchanged in the SETTINGS frame we saw at the start of the HTTP/2 section.
  • Test with the tools. curl -v --http2, curl --http1.1, browser DevTools "Protocol" column (right-click the table header in the Network tab), and openssl s_client -alpn h2 are enough for 95% of debugging.

Try it

Do these on your own machine with Node 22 and curl.

  1. Start the server h1.mjs from this lesson. Send GET /a and GET /b on one connection with nc, using printf. In the second request, leave out Connection: close and watch what happens when nc does not close. Why does nc hang?
  2. Run curl -sv http://127.0.0.1:8080/a http://127.0.0.1:8080/b and find the line that proves the connection was reused. Then run the same two URLs with curl --next between them (curl http://127.0.0.1:8080/a --next http://127.0.0.1:8080/b). Predict what the server log shows, then check.
  3. Start hol1.mjs. Change the server delay from 300 ms to 100 ms and the number of requests to 20. Predict the time for maxSockets 1, 4 and 20 before you run it.
  4. Make a self-signed certificate with openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -keyout k.pem -out c.pem -days 30 -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1", run h2.mjs, and call it with curl --http2 --cacert c.pem https://localhost:8443/hi. Which line of -v output tells you HTTP/2 was chosen? Then call it with --http1.1.
  5. Send a header with a capital letter from curl --http2 -H "X-Test: 1" to a Node HTTP/2 server that logs req.headers. What case does the server see?

Answers

  1. nc stays open waiting for more data or the end of the connection. With keep-alive the server does not close, and nc does not know the response is finished. The server closes it after its 5 second idle timeout. Add Connection: close or use nc -w1 (a one second timeout).
  2. The line Re-using existing connection with host 127.0.0.1 shows reuse. I expected --next to start a fresh connection, and I was wrong: I ran it and the server log showed one new TCP connection #28 for both /a and /b. curl keeps its connection pool across --next. To force a new connection for each request, run two separate curl commands, or send -H "Connection: close" (I did not test that variant).
  3. The server work is 100 ms per request. With one connection, 20 requests queue, so about 20 times 100 ms is 2000 ms. With four connections, 5 rounds, so about 500 ms. With 20, about 100 ms. (Plus a few ms of overhead.)
  4. * ALPN: server accepted h2 and * using HTTP/2. With --http1.1, the server reports HTTP/1.1 and connection #h1 in my version of the code.
  5. Lowercase, x-test: 1. HTTP/2 requires lower case header names.

Interview questions

What is the main problem HTTP/2 solves compared with HTTP/1.1? (Must know)

In HTTP/1.1 one connection answers one request at a time, in order, so a slow response blocks the ones behind it. Browsers work around it by opening about six connections per host, which costs handshakes and memory. HTTP/2 multiplexes many streams on one connection, so requests run in parallel. I measured 12 requests of 300 ms: with one HTTP/1.1 connection it took 3.6 seconds, with one HTTP/2 connection 0.32 seconds.

What is head-of-line blocking? Does HTTP/2 remove it? (Must know)

It means that one stuck item at the front holds up everything behind it. HTTP/1.1 has it at the HTTP level: responses come in request order on one connection. HTTP/2 removes that, but moves it to the TCP level. All streams share one TCP connection, and TCP delivers bytes in order, so one lost packet delays every stream until it is retransmitted. HTTP/3 removes this one too because QUIC streams are ordered independently.

Why did HTTP/3 move to UDP? (Must know)

TCP's in-order delivery is inside the operating system and in network devices, and I cannot change it without waiting years for everything on the internet to update. A new transport placed directly on IP would be blocked by many middleboxes. UDP passes through, so QUIC is built on top of it and implements reliability, streams, congestion control and encryption in user space. The benefit is per-stream ordering, a faster handshake because TLS 1.3 is combined with the transport handshake, and connection migration.

How does a client and server decide to use HTTP/2? (Must know)

For HTTPS, they use ALPN, an extension of the TLS handshake. The client lists protocols it speaks (h2, http/1.1) and the server picks one. I saw it in curl -v: ALPN: server accepted h2. For HTTP/3, the browser usually learns about it from an Alt-Svc response header (or a DNS record) and tries QUIC on later requests. For cleartext HTTP/2, called h2c, a client must know in advance (prior knowledge), because browsers do not use it.

A Node service calling another service gets random ECONNRESET errors on idle connections. What is going on? (Trap)

Probably a keep-alive timeout race. The server (or a load balancer in between) closed an idle connection at the same moment the client reused it from its pool. I would make sure the side that opens the connection (the client) has a shorter idle timeout than the side that closes it, check the load balancer idle timeout against the Node keepAliveTimeout (Node's default is 5 seconds), and retry idempotent requests once. I saw Node close an idle connection after about 5 seconds in my test.

Does moving to HTTP/2 mean we should bundle fewer files, or shard domains? (Senior signal)

It means bundling matters less, but I would not throw away bundling blindly. Many small files cost per-request overhead and compress worse than one large file, and caching granularity is a trade-off. Domain sharding is bad on HTTP/2 because it splits requests over several connections and loses the benefits of one well-tuned connection and shared header compression. I would measure with the real site rather than follow a rule.

What does HPACK do, and why not just gzip the headers? (Senior signal)

HPACK keeps a table of header fields seen on the connection and sends short references to them. In my test, repeated requests with a 400-byte cookie cost 42 bytes each on HTTP/2, against 548 bytes on HTTP/1.1. Gzip of headers was dropped because compressing secret values (cookies) together with attacker-controlled data leaks information through the compressed size (the CRIME attack). HPACK works with whole fields and a table, so it avoids that kind of leak. I know this from background reading, I did not reproduce the attack.

What are the risks of QUIC 0-RTT? (Senior signal)

0-RTT lets a returning client send a request in its first packets, saving a round trip. The data is not protected against replay: an attacker who records the packets can send them again. So a server should accept 0-RTT only for requests that are safe to repeat, like a GET for a public page, not for a payment POST. Many servers and CDNs restrict it to such requests or add replay protection.

Our users on mobile networks report that the site feels stuck, but desktop is fine. HTTP/2 is enabled. What might help? (Senior signal)

A stall on a lossy mobile link blocks all streams in a single TCP connection (TCP-level head-of-line blocking), so HTTP/2 can feel worse than several connections there. Enabling HTTP/3 helps because only the stream with the lost packet waits, and it also survives a change from Wi-Fi to mobile data. I would first measure on real devices with real traffic. I would check that UDP 443 is not blocked on the path and that the server sends Alt-Svc, since the first visit always uses TCP.

What changes in HTTP/2 when it comes to headers? (Trap)

Names must be lowercase, the request line becomes pseudo-headers (:method, :path, :scheme, :authority), and connection-specific headers like Connection, Keep-Alive and Transfer-Encoding are not allowed. The Host header is replaced by :authority. Code that compares header names case-sensitively, or a proxy that copies connection headers across protocol versions, can break. In my curl test, a header sent as X-Test arrives as x-test.

Why do browsers need TLS for HTTP/2 even though the standard allows cleartext? (Trap)

The standard allows h2c, but browser makers decided to support HTTP/2 only over TLS. One reason is that old proxies and middleboxes mishandle unknown traffic on port 80, while encrypted traffic passes through untouched. Another is that ALPN gives a clean way to negotiate the protocol inside the TLS handshake with no extra round trip. For service-to-service traffic inside a network, h2c is common, for example for gRPC behind a proxy.

How can you tell which HTTP version a request really used? (Must know)

From the client: curl -v shows using HTTP/2 and < HTTP/2 200, and curl -w "%{http_version}" prints it. In a browser, the Network tab has a Protocol column (h2, h3, http/1.1). From the server: in Node req.httpVersion, or the access log of the proxy. I would also run openssl s_client -alpn h2,http/1.1 to see what the server chooses. Remember that a CDN in front may speak HTTP/3 to the user and HTTP/1.1 to my app, so the two sides can differ.

Key takeaways

  • HTTP/1.1 is plain text over TCP. You can type it with nc. A message is a start line, headers, a blank line and a body, and the body length comes from Content-Length or chunked encoding.
  • Keep-alive reuses a TCP connection for many requests. The server closes it after an idle timeout (Node: 5 seconds), and a client that reuses a just-closed connection sees ECONNRESET. Keep the client's idle timeout shorter.
  • On one HTTP/1.1 connection responses come in order, so a slow one blocks the rest. Browsers open about six connections to work around it. In my test, 12 requests took 3638 ms on 1 connection, 609 ms on 6, and 310 ms on 12.
  • HTTP/2 uses binary frames, numbered streams, compressed headers (HPACK) and one connection. The same 12 requests took 323 ms on one connection. Header bytes per request fell from 548 to 42.
  • HTTP/2 is negotiated with ALPN inside TLS. Header names must be lowercase and connection headers are not allowed.
  • HTTP/2 still has head-of-line blocking at the TCP level: one stalled packet freezes all streams. My simulation showed all 6 streams late on HTTP/2 and only 1 of 6 on HTTP/1.1.
  • HTTP/3 runs over QUIC on UDP. It has per-stream ordering, a combined transport and TLS 1.3 handshake, connection IDs for migration, and encryption almost everywhere. Browsers learn about it from Alt-Svc, and UDP may be blocked, so always keep HTTP/2 working too.
  • You usually do not pick the HTTP version in code. Measure before promising speed-ups.

What's next

Next: TLS and Certificates: How HTTPS Really Works. We have used TLS in this lesson (for ALPN and for h2) without opening it. In the next lesson you will build your own certificate authority, sign certificates, watch the handshake, and meet the real error messages for expired, wrong-name and untrusted certificates. Practise now: run the h2.mjs server, open openssl s_client -connect 127.0.0.1:8443 -alpn h2 and find the certificate subject in the output.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series