L7 · transport · reviewed
HTTP
Hypertext Transfer Protocol
One set of semantics — methods, status codes and fields — carried over three quite different transports, which is why "which HTTP version" changes performance and almost nothing else.
Presenter modeEmbed this figure
Why it exists
HTTP is two things that are easy to conflate: a set of semantics — methods, status codes, header fields — and a series of quite different ways of putting those on a wire. RFC 9110 defines the first and is shared by every version. Connections, Clients, and ServersRFC 9110 · INTERNET STANDARD · June 2022
The versions exist because the transport kept being the bottleneck. HTTP/1.1 allowed one request at a time per connection; HTTP/2 multiplexed many onto one; HTTP/3 changed transport entirely so that one lost packet stalls one stream rather than all of them. Frame FormatRFC 9113 · PROPOSED STANDARD · June 2022
Which version is in use is decided by ALPN during the TLS or QUIC handshake, not by anything in the URL. A `https://` address says nothing about it.
A promise you cannot take back
Freshness is a contract with every cache between you and the reader. Once made, there is no mechanism to recall it.
The origin serves an asset with a one-year freshness lifetime. Every cache on the path may now serve it for a year without asking again. Origin: Cache-Control max-age=31536000. CDN: Holds fresh for a year. Browser cache: Holds fresh for a year.
- Link
- Blocking
- Packet in flight
- Discarded
- Emphasis
Text equivalent of this diagram
| Element | Kind | State |
|---|---|---|
| Origin | host | Cache-Control: max-age=31536000 |
| CDN | router | Holds: fresh for a year |
| Browser cache | host | Holds: fresh for a year |
| Browser cache — CDN | link | up |
| CDN — Origin | link | up |
The origin serves an asset with a one-year freshness lifetime. Every cache on the path may now serve it for a year without asking again.
What changed
- Origin: Cache-Control → max-age=31536000
- CDN: Holds → fresh for a year
- Browser cache: Holds → fresh for a year
How it works
From HTTP/2 onward the protocol is binary and framed. Every frame carries a stream identifier, and stream zero is the connection itself rather than a request — SETTINGS, PING and GOAWAY all use it. Stream StatesRFC 9113 · PROPOSED STANDARD · June 2022
Multiplexing removes head-of-line blocking at the HTTP layer and not at the transport. Every stream on an HTTP/2 connection shares one TCP sequence space, so a single lost segment stalls all of them — which is why HTTP/2 can be slower than HTTP/1.1 on a lossy path.
Caching is a contract, not a hint. A response served with a freshness lifetime may be reused for that long without contacting the origin at all, so there is no request to intercept and nothing to change. Cache-ControlRFC 9111 · INTERNET STANDARD · June 2022
The name a client is asking for appears twice: in the TLS server name extension, before encryption, and in the Host field inside the request. They are set independently and can disagree. Host and :authorityRFC 9110 · INTERNET STANDARD · June 2022
On the wire
Constructed examples, encoded from the field table below them — not captured traffic.
- TCP and TLS
- Almost always port 443 inside TLS, with the protocol agreed by ALPN during the handshake rather than by an upgrade. RFC 9113
- Frame header
- Length, type, flags, and the stream this frame belongs to. RFC 9113
- Frame payload
- Format determined by the type: headers, data, settings, a window update, a reset. RFC 9113
Configure it
listen 443 ssl; http2 on; http3 on; add_header Alt-Svc 'h3=":443"; ma=86400' always;The version is chosen by ALPN during the handshake. Alt-Svc is how a client that arrived over HTTP/2 learns HTTP/3 is available for next time — HTTP/3 cannot be negotiated on a connection that is already TCP.
location ~* \.[0-9a-f]{6,}\.(js|css|woff2)$ { add_header Cache-Control "public, max-age=31536000, immutable"; }A year, and safely, because the hash in the filename means new content is a new URL. `immutable` additionally stops a browser revalidating on reload, which is otherwise a burst of pointless conditional requests.
Common mistake: Applying this to a path without a content hash. There is no way to recall a response a browser holds as fresh — the cache is doing what it was told, for a year.
RFC 9111 §5.2
location = /index.html { add_header Cache-Control "no-cache"; etag on; }`no-cache` means store it and revalidate before use, which is what a small document that changes should do. The revalidation is a 304 with no body, so the cost is a round trip rather than a download.
RFC 9111 §4.3
location /account/ { add_header Cache-Control "private, no-store"; }Two directives because they answer different questions: `private` keeps it out of shared caches, `no-store` keeps it out of any cache. A personalised page marked only `no-cache` may legitimately be stored by a CDN.
RFC 9111 §5.2
if ($host != $ssl_server_name) { return 421; }Rejects a request whose Host disagrees with the name in the TLS handshake. 421 Misdirected Request is the status that exists for precisely this, and the check is what stops a filter that inspects only SNI being bypassed.
RFC 9110 §7.2
Verify
curl -sI https://host/app.9f2c1a.js | grep -i cache-control- The promise being made, in full.
curl -sI --http3 https://host/ -o /dev/null -w "%{http_version}\n"- Which version was actually negotiated.
curl -sI -H "Host: other.example" --resolve host:443:IP https://host/- What a mismatched Host receives.
curl -sI -H "If-None-Match: <etag>" https://host/- A 304, confirming validation works.
Caveats
- A long freshness lifetime on a path without a content hash cannot be recalled from browser caches.
- `no-cache` permits storage; only `no-store` forbids it.
- HTTP/3 cannot be negotiated on an existing TCP connection — Alt-Svc advertises it for next time.
When it breaks
Symptom first, because that is what you have when it happens.
A site works over HTTP and returns the wrong content, or a certificate error, over HTTPS on the same host.
Narrow it down
- Check whether several names share one address.
- Confirm the client sends a server name indication.
- Compare which virtual host answers in each case.
Cause
Name-based virtual hosting relies on the Host field, which is inside the request and therefore only readable after TLS is established. Certificate selection has to happen before that, from SNI instead.
Fix
Ensure SNI is sent and the server has a certificate for that name. Host and SNI are two mechanisms carrying the same name at two layers.
Host and :authorityRFC 9110 · INTERNET STANDARD · June 2022A change is deployed and some users keep seeing the old version for days.
Narrow it down
- Read the response cache directives, not just the age.
- Check for an intermediary cache between the client and the origin.
- Look at whether the asset URL changed with the content.
Cause
A long freshness lifetime was served with an immutable URL. Once a response is cached with a year of freshness there is no mechanism to recall it — the cache is behaving exactly as instructed.
Fix
Cache immutable assets under content-addressed URLs and give documents a short lifetime with validation. Cache directives are a promise; do not make one you may need to break.
Cache-ControlRFC 9111 · INTERNET STANDARD · June 2022A request works from curl and fails from a browser, or the reverse, with the same URL.
Narrow it down
- Compare the full request headers each client sends.
- Check for a preflight OPTIONS request the browser makes and curl does not.
- Look at whether the difference is cookies, compression, or protocol version.
Cause
The two clients are not sending the same request. A browser adds an origin, may preflight, sends cookies, negotiates HTTP/2, and applies a security policy — none of which curl does by default.
Fix
Reproduce the browser’s actual request rather than an approximation of it. Copying the request as curl from the network panel removes the guesswork.
Moving a site from HTTP/1.1 to HTTP/2 made it slower for users on poor connections and faster for everyone else.
Narrow it down
- Compare loss rates between the user populations that improved and regressed.
- Check how many streams share the single connection during a page load.
- Look at whether retransmissions correlate with the stalls.
Cause
Every stream shares one TCP sequence space, so one lost segment stalls all of them. HTTP/1.1’s six connections meant a loss affected a sixth of the work — an accidental virtue that multiplexing removes.
Fix
Offer HTTP/3 for those clients. QUIC tracks loss per stream, which is precisely the problem this is.
HTTP/3 Protocol OverviewRFC 9114 · PROPOSED STANDARD · June 2022A user is served a page containing another user’s data, from a CDN.
Narrow it down
- Check the cache directives on the personalised response.
- Look for `private` or `no-store` — and for their absence.
- Check whether the cache key includes whatever distinguishes the users.
Cause
A shared cache may store any response that does not forbid it. `no-cache` does not forbid storage — it requires revalidation — so a personalised response marked `no-cache` and keyed only on the URL is served to whoever asks next.
Fix
Use `private` for per-user responses and `no-store` for anything sensitive, and make sure the cache key includes the identity the response depends on.
Cache-ControlRFC 9111 · INTERNET STANDARD · June 2022
Design notes
Never promise a freshness lifetime longer than you are willing to be wrong for. A CDN can be purged; the browser caches you cannot reach are why immutable assets need content-addressed URLs.
Compare SNI against Host at any terminator in front of shared infrastructure. A filter that inspects only the first sees an allowed name while the request reaches something else — the mechanism behind domain fronting.
Know which directive you mean. `no-cache` means store it and revalidate; `no-store` means keep no copy. Confusing the two is how private responses end up in a shared cache. Cache-ControlRFC 9111 · INTERNET STANDARD · June 2022
When a request behaves differently from two clients, compare the bytes rather than the theory. A browser adds an origin, may preflight, sends cookies and negotiates a different version — copying the request as curl from the network panel removes the guesswork.
Misconceptions
- “HTTP/2 is faster because it compresses the body.”
- Body compression is content encoding and predates it. HTTP/2 multiplexes requests over one connection and compresses headers, which removes head-of-line blocking at the HTTP layer and the cost of many connections — the body is unchanged. IntroductionRFC 9113 · PROPOSED STANDARD · June 2022
- “A 404 means the resource does not exist.”
- It means the server is declining to say more than that. Returning 404 instead of 403 to hide the existence of a resource from an unauthorised caller is a deliberate and common pattern. 404 Not FoundRFC 9110 · INTERNET STANDARD · June 2022
- “The Host field and the TLS server name are the same thing.”
- They carry the same name at different layers and are set independently. SNI is in the ClientHello, before encryption; Host is inside the encrypted request. They can disagree, and domain fronting depends on exactly that. Host and :authorityRFC 9110 · INTERNET STANDARD · June 2022
- “`no-cache` tells a cache not to store the response.”
- It tells a cache to store it and revalidate before every use. `no-store` is the directive that forbids keeping a copy, and mixing them up is how personalised responses reach a shared cache. Cache-ControlRFC 9111 · INTERNET STANDARD · June 2022
More walkthroughs
Three transports, one meaningdesign-choice
The method, the status code and the fields are the same in all three versions. What changes is how many requests can be in flight and what one lost packet costs.
A GET is a GET in all three. Methods, status codes, and header fields are one specification, and the version is a transport choice made underneath them. Client: Request GET /index.html. HTTP/1.1 · TCP. HTTP/2 · TLS on TCP. HTTP/3 · QUIC on UDP. Server.
- Link
- Blocking
- Packet in flight
- Discarded
- Emphasis
Text equivalent of this diagram
| Element | Kind | State |
|---|---|---|
| Client | host | Request: GET /index.html |
| HTTP/1.1 · TCP | zone | — |
| HTTP/2 · TLS on TCP | zone | — |
| HTTP/3 · QUIC on UDP | zone | — |
| Server | host | — |
| Client — HTTP/1.1 · TCP | link | up |
| Client — HTTP/2 · TLS on TCP | link | up |
| Client — HTTP/3 · QUIC on UDP | link | up |
| HTTP/2 · TLS on TCP — Server | link | up |
A GET is a GET in all three. Methods, status codes, and header fields are one specification, and the version is a transport choice made underneath them.
What changed
- Client: Request → GET /index.html
- Identical semantics either way
The same name, twice, at two layersfailure
A certificate is chosen from one field and the virtual host from another. They usually match, and nothing requires them to.
SNI is in the ClientHello, before encryption. Host is inside the request, after it. Two fields, two layers, the same name — usually. Client: SNI shop.example.com, Host shop.example.com. TLS terminator. Application.
- Link
- Blocking
- Packet in flight
- Discarded
- Emphasis
Text equivalent of this diagram
| Element | Kind | State |
|---|---|---|
| Client | host | SNI: shop.example.com · Host: shop.example.com |
| TLS terminator | firewall | — |
| Application | host | — |
| Client — TLS terminator | link | up |
| TLS terminator — Application | link | up |
SNI is in the ClientHello, before encryption. Host is inside the request, after it. Two fields, two layers, the same name — usually.
What changed
- Client: SNI → shop.example.com
- Client: Host → shop.example.com
Terms
- X-Forwarded-For
- A comma-separated list in which each hop appends the address it received the connection from. Never standardised; read it from the right by the hops you operate, because the left end is whatever the client sent.
- SNI
- Server Name Indication: the hostname a client names in the TLS handshake, in the clear, so one address can present the right certificate for many names.
- Idempotent method
- One whose effect is the same however many times it is applied, so a client or proxy may retry after a failure without knowing whether the first attempt was processed. It is why a lost GET can be repeated and a lost POST cannot.