Skip to content

HTTP versions

The meaning of HTTP (methods, status codes, headers) has barely changed since 1997. Each new version changed how those messages are carried: HTTP/1.1 reuses a connection, HTTP/2 multiplexes many requests over one TCP connection, HTTP/3 does the same over QUIC on UDP so one lost packet no longer stalls every request.

Every version removed the worst delay of the one before.

  • HTTP/0.9 (1991). One line: GET /page.html. The server sends HTML and closes the connection. No headers, no status codes, no other content types.
  • HTTP/1.0 (RFC 1945, May 1996). Adds a version in the request line, a status line, and headers. Content-Type makes images and other files possible. Still one TCP connection per request, so every image pays a new handshake and a new slow start.
  • HTTP/1.1 (RFC 2068, January 1997; RFC 2616, June 1999; current text RFC 9110 and 9112, June 2022). Connections are persistent by default. The Host header lets many sites share one IP address. Chunked transfer coding lets a server stream a body of unknown length. Pipelining is added and fails in practice. What still hurts: one request at a time per connection.
  • SPDY (Google) showed that multiplexing over one connection works on the real Internet. It became the base of HTTP/2.
  • HTTP/2 (RFC 7540, May 2015; current text RFC 9113, June 2022). Binary frames, many concurrent streams on one connection, header compression. What still hurts: all streams share one TCP byte stream, so one lost packet stalls all of them.
  • QUIC (RFC 9000, May 2021) and HTTP/3 (RFC 9114, June 2022). The transport moves to UDP and into user space. Streams are known to the transport, so loss on one does not block the others. The TLS 1.3 handshake is merged with the transport handshake.

Since 2022 the semantics live in one document (RFC 9110) and each version is only a wire mapping: RFC 9112, 9113, 9114.

  1. Text on the wire. A request line, header lines, an empty line, an optional body. You can type it by hand with nc.
  2. Keep-alive. The connection stays open after a response. The next request reuses it and skips the TCP and TLS handshakes. Connection: close ends it. In HTTP/1.0 persistence was an opt-in with Connection: keep-alive.
  3. One request at a time. A connection carries one request, then its complete response, then the next. A slow response blocks everything queued behind it. This is head-of-line (HOL) blocking at the application layer.
  4. Pipelining. The client sends several requests without waiting. The server must answer in the same order. So a slow first response still blocks the rest, only idempotent requests can be retried safely after a failure, and many proxies handled it wrongly. Browsers do not enable it.
  5. Parallel connections instead. Browsers open about 6 connections per origin (earlier browsers used 2 to 3). Each one costs a handshake, memory on both sides, and its own congestion window.
  6. Workarounds of that era: domain sharding (spread assets over static1, static2 to get 6 connections each), image sprites, bundling files. All of them are counterproductive on HTTP/2.
  1. Negotiation. Over TLS the client offers h2 in ALPN (Application-Layer Protocol Negotiation) and the server picks it during the handshake, so it costs no extra round trip. Browsers use HTTP/2 only over TLS. Clear text HTTP/2 needs prior knowledge that the server speaks it. The old Upgrade: h2c mechanism is deprecated in RFC 9113.
  2. Preface. The client sends a fixed 24 byte string that starts with PRI * HTTP/2.0, then a SETTINGS frame. The server answers with its own SETTINGS.
  3. Binary framing. Everything is a frame with a 9 byte header: 24 bit length, 8 bit type, 8 bit flags, 31 bit stream identifier. Types include HEADERS, DATA, SETTINGS, WINDOW_UPDATE, RST_STREAM, PING, GOAWAY. The default maximum frame payload is 16,384 bytes.
  4. Streams. One request and its response are one stream. Client-initiated streams have odd identifiers, server-initiated ones even. Stream 0 is the connection itself.
  5. Multiplexing. Frames of different streams are interleaved on one TCP connection. A slow response no longer blocks others. One connection per origin is enough. SETTINGS_MAX_CONCURRENT_STREAMS is unlimited by default and the RFC recommends at least 100.
  6. Flow control. Every stream and the whole connection have a receive window. Both start at 65,535 bytes and grow with WINDOW_UPDATE.
  7. HPACK (RFC 7541) compresses headers. A static table holds common header fields, a dynamic table (4,096 bytes by default) remembers headers already sent on this connection, and strings are Huffman coded. A repeated cookie or user-agent becomes one or two bytes.
  8. Cancel without closing. RST_STREAM ends one stream. GOAWAY tells the peer to stop opening streams, which is how a server drains gracefully.
  9. Server push let a server send responses nobody asked for. It rarely helped. Chrome disabled it by default in version 106. Use 103 Early Hints or preload instead.
  10. Priorities. The stream dependency tree of RFC 7540 is deprecated. The replacement is the Priority header of RFC 9218, shared with HTTP/3.

The problem that remains: TCP head-of-line blocking. TCP guarantees one ordered byte stream and knows nothing about HTTP/2 streams. If one packet is lost, the kernel holds back every byte after it until the retransmission arrives, even bytes that belong to other streams. On a lossy link a single HTTP/2 connection can be worse than six HTTP/1.1 connections, because six connections lose independently. RFC 9113 says it plainly: TCP head-of-line blocking is not addressed by this protocol.

  1. UDP. QUIC runs over UDP, usually port 443. Reliability, ordering, congestion control and loss recovery are implemented by QUIC itself, in the application or a library, not in the kernel.
  2. Encryption is built in. QUIC always uses TLS 1.3. The TLS handshake messages travel inside QUIC packets. Almost everything is encrypted, including most of the packet header, so middleboxes cannot read or rewrite transport state.
  3. 1-RTT setup. One round trip sets up transport and keys together. TCP plus TLS 1.3 needs two. With a ticket from an earlier visit the client can send a request in its first flight (0-RTT). As with TLS, early data can be replayed, so it is only for requests that are safe to repeat.
  4. Streams in the transport. QUIC delivers each stream in order on its own. A lost packet delays only the streams whose data was in it. HTTP/3 puts each request on one bidirectional stream and uses unidirectional streams for control data.
  5. Connection IDs and migration. A TCP connection is identified by source and destination IP and port. Change network and it dies. A QUIC connection is identified by connection IDs chosen by the endpoints. When a phone moves from Wi-Fi to cellular, the client sends from the new address, the server checks the new path with PATH_CHALLENGE and PATH_RESPONSE, and the connection continues. Only the client can migrate, only after the handshake is confirmed, and it uses a fresh connection ID on the new path so observers cannot link the two.
  6. QPACK (RFC 9204) replaces HPACK. HPACK assumes all header blocks arrive in one total order, which would bring head-of-line blocking back. QPACK sends dynamic table updates on a dedicated encoder stream and acknowledgements on a decoder stream. Its static table has 99 entries. The dynamic table capacity and the number of streams allowed to wait for table updates both default to 0, so by default no stream can block on another.
  7. Protection against abuse. A client’s first datagram must be padded to at least 1,200 bytes, and until the server has validated the client’s address it may send at most 3 times the bytes it received. This limits reflection attacks with spoofed source addresses.
HTTP/2 ON TCP one byte stream, strict order A1 B1 C1 A2 C2 lost TCP holds C1, A2, C2 until B1 is resent stream A: A2 held back stream B: B1 missing stream C: C1, C2 held back HTTP/3 ON QUIC independent streams over UDP A1 B1 C1 A2 C2 lost QUIC delivers A and C, resends only B1 stream A: complete stream B: waits for B1 stream C: complete
Five packets carry data for streams A, B and C, and the second one is lost. Green packets reach the application. TCP delivers only what came before the gap. QUIC keeps one order per stream, so only stream B waits.

A URL does not say which version to use, and a client cannot know that UDP 443 works. Two mechanisms:

  1. Alt-Svc header (RFC 7838). The first visit goes over TCP with HTTP/2 or HTTP/1.1. The response carries:

    alt-svc: h3=":443"; ma=86400

    This says the same origin is available over HTTP/3 on UDP port 443, and the hint is valid for 86,400 seconds. Without ma the default is 24 hours. The client remembers it and tries QUIC for later requests. The QUIC endpoint must present a certificate valid for the original host name. Clients drop cached alternatives on a network change unless the header says persist=1.

  2. HTTPS DNS record (RFC 9460). The answer example.com. HTTPS 1 . alpn="h3,h2" tells the client before any connection that HTTP/3 is available, so even the first request can use QUIC. See DNS.

If QUIC does not connect, the client falls back to TCP. Clients usually race both so a blocked UDP path costs little. curl, for example, starts the TCP attempt after 100 ms without a reply on QUIC and after 200 ms in any case.

Helps most:

  • Lossy networks. Mobile, congested Wi-Fi, long international paths. No cross-stream stall on loss.
  • High latency paths. One round trip less on every new connection, zero with 0-RTT.
  • Clients that change networks. Connection migration keeps downloads and calls alive across a Wi-Fi to cellular switch.
  • Pages with many small resources from one origin.

Helps little or not at all:

  • Clean, low latency links, such as inside a data centre. With no loss there is no TCP head-of-line blocking to remove, and connections are long-lived so the handshake saving is rare.
  • One large download. A single stream has nothing to be blocked by. Throughput then depends on congestion control and CPU cost.
  • Service to service traffic. gRPC is defined on HTTP/2, and kernel TCP is cheaper per byte.
  • UDP 443 must be open end to end: firewalls, security groups, the load balancer listener. Some corporate and public networks block or throttle UDP. Clients fall back silently, so the only sign is a lower HTTP/3 share in your logs. You must keep HTTP/2 or HTTP/1.1 on TCP 443 anyway.
  • CPU. QUIC is implemented in user space and encrypts every packet. It generally costs more CPU per byte than kernel TCP with TLS, which has had decades of offload work.
  • Load balancers. A layer 4 balancer that hashes on addresses and ports sends a migrated connection to the wrong back end. It has to route on the connection ID. Every instance behind it must be able to recognise the connection or reset it cleanly.
  • Observability. A packet capture shows almost nothing: no visible sequence numbers, ACKs or stream boundaries. TCP tools and kernel counters (retransmits, conntrack TCP state) do not apply. You rely on the QUIC library’s own logs (qlog), on TLS key logging with SSLKEYLOGFILE for Wireshark, and on server metrics.
  • Path requirements. QUIC does not allow IP fragmentation of its datagrams, and the path must carry 1,200 byte UDP payloads.
  • Software support varies by proxy, CDN, cloud load balancer and client library. Many stacks terminate HTTP/3 at the edge and speak HTTP/2 or HTTP/1.1 to the origin.
HTTP/0.9 HTTP/1.0 HTTP/1.1 HTTP/2 HTTP/3
Year 1991 1996 1997 2015 2022
Specification none RFC 1945 RFC 9112 RFC 9113 RFC 9114
Transport TCP TCP TCP TCP QUIC over UDP
What it fixed a way to fetch a document headers, status codes, content types connection reuse, Host, chunked bodies, caching rules application head-of-line blocking, header overhead transport head-of-line blocking, setup latency, network changes
What still hurt HTML only one connection per request one request at a time per connection TCP head-of-line blocking UDP reachability, CPU, tooling
HTTP/1.1 HTTP/2 HTTP/3
Transport TCP TCP QUIC over UDP
Encryption optional optional in the RFC, TLS in every browser always, TLS 1.3 inside QUIC
Wire format text binary frames binary frames on QUIC streams
Requests per connection one at a time many, interleaved streams many, independent streams
Connections per origin (browser) about 6 1 1
Head-of-line blocking, application yes no no
Head-of-line blocking, transport yes, per connection yes, hits all streams at once no, per stream only
Header compression none HPACK QPACK
Round trips before first request (new connection) 2 with TLS 1.3, 3 with TLS 1.2 2 with TLS 1.3, 3 with TLS 1.2 1
0-RTT resumption only with TLS 1.3 early data, TCP handshake still needed same as HTTP/1.1 yes, transport and TLS together
Flow control TCP only per stream and per connection, on top of TCP per stream and per connection, in QUIC
Loss recovery and congestion control kernel TCP kernel TCP QUIC, in user space
Survives a change of IP address no no yes, connection IDs
Cancel one request close the connection RST_STREAM reset the QUIC stream
Server push no defined, disabled in Chrome since 106 defined, with its own frames
Prioritisation none RFC 7540 tree (deprecated), RFC 9218 RFC 9218 Priority header
Negotiation default ALPN h2 Alt-Svc header or HTTPS DNS record, ALPN h3
Clear text mode yes h2c with prior knowledge none
Middlebox visibility full without TLS TCP state visible, payload encrypted almost nothing visible
Debug by hand nc, telnet curl -v, nghttp curl --http3, qlog
Best fit simple clients, legacy, internal tools default for web and APIs, gRPC public web traffic to browsers and mobile apps
Where Version affected Cause Fixed by
Application HTTP/1.1 responses must come back in request order on a connection HTTP/2 streams
Transport HTTP/1.1 and HTTP/2 TCP delivers one ordered byte stream, a gap blocks everything behind it QUIC streams
Header compression HPACK if it were used over QUIC the dynamic table needs updates in order QPACK, which lets the encoder avoid references that could block
Symptom Cause Check
Site never uses HTTP/3 no alt-svc header and no HTTPS record, or UDP 443 closed curl -sI https://example.com/ | grep -i alt-svc, dig example.com HTTPS, curl --http3-only
First request is HTTP/2, later ones HTTP/3 normal: Alt-Svc is learned from the first response add an HTTPS DNS record to allow HTTP/3 on the first request
--http3-only times out, --http2 works UDP 443 blocked by a firewall, security group or missing UDP listener nc -vzu example.com 443 is not conclusive; capture with tcpdump -ni any udp port 443 on the server
HTTP/3 works on one network, not another that network drops UDP compare networks; the client should fall back to TCP
Clients negotiate http/1.1 instead of h2 the proxy does not offer h2 in ALPN, or a TLS-inspecting middlebox strips it openssl s_client -connect example.com:443 -alpn h2,http/1.1 </dev/null | grep ALPN
gRPC fails through a proxy a hop downgrades to HTTP/1.1, and gRPC needs HTTP/2 end to end proxy protocol settings for the upstream; curl -v --http2 to each hop
Uploads or streams stall on HTTP/2 a flow control window is exhausted and nobody sends WINDOW_UPDATE nghttp -nv https://example.com/, look at window sizes
Requests fail in bursts with GOAWAY or REFUSED_STREAM the server is draining, or the concurrent stream limit is hit curl -v shows the frame; check MAX_CONCURRENT_STREAMS in the server SETTINGS
Long requests break after a deploy of the load balancer QUIC or HTTP/2 connections pinned to an instance that went away connection draining settings, GOAWAY on shutdown
HTTP/1.1 page loads slowly with many assets 6 connections per origin, each serial enable HTTP/2 at the edge

Which version did I get:

Terminal window
curl -sI https://example.com/ -o /dev/null -w '%{http_version}\n' # prints 1.1, 2 or 3
curl -sIv https://example.com/ 2>&1 | grep -E 'ALPN|HTTP/'

Force a version:

Terminal window
curl --http1.1 -sI https://example.com/
curl --http2 -sI https://example.com/ # the default for https URLs
curl --http2-prior-knowledge -sI http://localhost:8080/ # clear text HTTP/2, no upgrade
curl --http3 -sI https://example.com/ # try QUIC, fall back to TCP
curl --http3-only -sI https://example.com/ # QUIC or fail

--http3 needs a curl built with HTTP/3. Check it:

Terminal window
curl -V | grep -o 'HTTP3'

Discovery:

Terminal window
curl -sI https://example.com/ | grep -i alt-svc
# alt-svc: h3=":443"; ma=86400
dig +short example.com HTTPS
# 1 . alpn="h3,h2" ipv4hint=192.0.2.10
curl --alt-svc altsvc.txt -sI https://example.com/ # remember Alt-Svc between runs and upgrade next time

Talk HTTP/1.1 by hand, to see that it is only text:

Terminal window
printf 'GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' | nc example.com 80

In a browser, the Network tab of the developer tools has a Protocol column that shows http/1.1, h2 or h3 per request.

HTTP/0.9, HTTP/1.0, HTTP/1.1, HTTP/2, HTTP/3, RFC 9110, RFC 9112, RFC 9113, RFC 9114, persistent connection, keep-alive, pipelining, head-of-line blocking, HOL blocking, 6 connections per origin, domain sharding, Host header, chunked transfer coding, SPDY, binary framing, frame, stream, multiplexing, SETTINGS, MAX_CONCURRENT_STREAMS, WINDOW_UPDATE, flow control, RST_STREAM, GOAWAY, HPACK, QPACK, static table, dynamic table, server push, 103 Early Hints, Priority header, ALPN, h2, h2c, h3, QUIC, UDP 443, RFC 9000, 1-RTT, 0-RTT, connection ID, connection migration, PATH_CHALLENGE, anti-amplification, Alt-Svc, HTTPS DNS record, qlog, SSLKEYLOGFILE, gRPC, curl –http2, curl –http3, curl –http3-only.