The DNS article ended with a name turned into an address, and the TCP and UDP article ended with a reliable byte stream between two programs.
HTTP is what runs on that stream, and it is the layer programmers touch most directly.
And it promises far less than most people assume. RFC 1945, from 1996, described itself as "a generic, stateless, object-oriented protocol". The important word is the middle one. It remembers nothing, and nearly everything built in the thirty years since has been about the consequences of that word.
HTTP promises less than people think
RFC 9110, the current edition, is even more direct — "HTTP is defined as a stateless protocol, meaning that each request message's semantics can be understood in isolation".
Every request has to explain itself completely without relying on the one before it. The server has no duty to remember who you are or what you just asked for.
This is not a defect; it is what makes systems scalable. Because nobody has to remember anything, the next request can go to any machine at all. Were HTTP stateful, ten servers behind a load balancer would be a problem rather than a solution.
The price is that everything which does need remembering has to find its own home. Cookies, tokens and sessions are all machinery invented to bolt memory back onto a protocol deliberately built without it.
If you have never looked, start here
One HTTP request is visible in its entirety with a single command, and the response that comes back is entirely readable.
$ curl -sI --http1.1 https://netkubelab.com/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: public, max-age=0, must-revalidate
Every line is plain text a person can read without a decoding tool. That is the single reason HTTP beat the other protocols of its era — it can be debugged by eye.
Keep three of those lines in mind: Transfer-Encoding, Connection and Cache-Control. All three become sections of their own below.
What a request and a response look like
RFC 9112 gives the grammar of the first line very briefly.
request-line = method SP request-target SP HTTP-version
status-line = HTTP-version SP status-code SP [ reason-phrase ]
SP is one space. That is all of it. No length, no counter, no binary version number.
The empty line is the boundary
After the first line come the headers, one field per line. Then the headers end with a single empty line. Nothing else announces the end.
That small detail has large effects. A recipient must read line by line until it meets the empty line, which means there is no way to know in advance how long the headers will be — and that is why every server has its own limit on the number of header lines and their length.
A header is only a name and a value
No types, no structure. A name, a colon, a value. And RFC 9110 states that "Field names are case-insensitive".
That sounds unimportant until you see two real responses side by side. The HTTP/1.1 response above uses Content-Type with capitals; the HTTP/2 response below uses content-type entirely in lowercase.
The reason is not taste. RFC 9113 requires that "A field name MUST NOT contain uppercase characters", because once case carries no meaning, forcing a single form compresses better and removes a conversion before every comparison.
Methods, and three properties that are not the same thing
People tend to remember GET as reading and POST as writing, which is too coarse. What the RFC actually defines is three clearly separate properties.
safe — RFC 9110 defines it as "its defined semantics are essentially read-only; that is, the client does not request, and does not expect, any state change on the origin server"
idempotent — "the intended effect of a request ... is the same whether the request is sent once or multiple times with identical request fields"
cacheable — the response may be stored and reused
method safe idempotent cacheable
GET y y y
HEAD y y y
POST - - *
PUT - y -
DELETE - y -
OPTIONS y y -
Notice that PUT and DELETE are not safe but are idempotent, the box people overlook most often. Deleting the same thing ten times has the same result as deleting it once, even though it changes state.
And POST's * means cacheable only when explicitly declared, never by default.
Why idempotence matters for retries
RFC 9110 spells out the consequence: "automatic retry logic for requests that use non-idempotent methods is not recommended, since the intended effect might have already been applied on the origin server, even if the response has not been received by the client".
That sentence explains a great many bugs in real systems. A sender cannot distinguish a request that never arrived from one that arrived and whose response was lost. From the sender's side the two look identical.
If the method is idempotent, guessing wrong costs nothing — resending gives the same result. If it is not, guessing wrong means a duplicate.
Status codes
The first digit is all you need to remember. RFC 9110 divides them into five classes.
1xx informational not finished, wait
2xx successful it worked
3xx redirection do one more thing
4xx client error the request had a problem
5xx server error the server had a problem
The line between 4xx and 5xx is about whose fault it is, not how severe it is. Malformed JSON gets a 400, which is the sender's fault. A database falling over gets a 500, which is not.
The code people most often file in the wrong class is 304, which is a 3xx and not a 2xx, because it does not report success. It says to go and use what you already have, which is an instruction to do something further, exactly like a 301 saying to go elsewhere.
Where does the body end
The question sounds easy until you learn that RFC 9112 needs eight rules in priority order to answer it. They shorten to four main cases.
1 Transfer-Encoding present chunked, ends at the zero-size chunk
2 Content-Length present count that many octets
3 request with neither the body length is zero
4 response with neither read until the connection closes
Content-Length
The most straightforward: state the byte count up front, and the recipient counts to it and stops.
The limitation is built in. The sender must know the whole size before sending the first byte, which is impossible when the content is produced as it goes — a result being computed progressively, for instance.
chunked
The solution is to send it in pieces, each announcing its own size in hex before it, with a zero-size chunk ending the lot.
The sender can therefore start immediately without knowing the total, which is why the response from this very site above uses Transfer-Encoding: chunked — the HTML goes out before it has finished being built.
And the mechanism disappears entirely in HTTP/2. RFC 9113 lists Transfer-Encoding among the fields that "MUST NOT be used in HTTP/2 connections", because chunking became the framing layer's job instead.
When two ends read the same message differently
Those four rules are in priority order for a reason. If one message carries both fields, RFC 9112 says "the Transfer-Encoding overrides the Content-Length".
The trouble is that the if really happens, and not everybody follows the same rule.
In a real system one request passes through several hands — a proxy, a load balancer, a filter — before reaching the actual server. If one of them reads Content-Length while another reads Transfer-Encoding, the two see messages of different lengths.
The one reading the shorter length believes the message ended, and the remainder sits in the buffer of a shared connection. When the next person sends a request on that shared connection, the remainder goes in front of theirs.
The result is that an attacker writes part of somebody else's request without touching their machine at all. It is called request smuggling.
RFC 9112 names it directly, saying such a message "might indicate an attempt to perform request smuggling ... and ought to be handled as an error", and sets the practical rule that even if you answer, "the server MUST close the connection after responding to such a request".
That instruction to close is the heart of the fix, because with no shared connection left there is no next request to go in front of.
One connection, one thing at a time
The single problem driving every version change in HTTP is the sentence in this heading.
HTTP/1.0 — a new connection every time
RFC 1945 records the practice of its era: "current practice requires that the connection be established by the client prior to each request and closed by the server after sending the response".
One request, one connection, which means paying for a fresh TCP three-way handshake every time. A page with twenty images paid it twenty-one times.
HTTP/1.1 — same connection, still one at a time
RFC 9112 says "HTTP/1.1 defaults to the use of 'persistent connections'" — the next request goes down the same wire.
It also allows pipelining, sending several requests without waiting. But the RFC states the condition that destroys its benefit in the same breath: a server "MUST send the corresponding responses in the same order that the requests were received".
Answering in the order received means that if the first response is slow, the second and third — ready and waiting — must wait too. Pipelining was therefore disabled by default in essentially every browser, and the practical answer became opening several connections at once instead.
HTTP/2 — stop being text
RFC 9113 summarises the problem itself: "HTTP/1.0 allowed only one request to be outstanding at a time on a given TCP connection. HTTP/1.1 added request pipelining, but this only partially addressed request concurrency and still suffers from application-layer head-of-line blocking".
The answer was to stop being text and start being frames.
bits field
24 length
8 type
8 flags
1 reserved
31 stream identifier
---
72 bits = 9 bytes
The critical field is the stream identifier, because it lets frames belonging to different jobs interleave on one wire. The RFC defines a stream as "an independent, bidirectional sequence of frames", and the word independent is the whole point.
The price is losing what made HTTP win in the first place. It can no longer be read by eye; a frame decoder is now always required.
Where the queue forms
This follows directly from the TCP and UDP article, and it is a good example of solving a problem at the wrong layer.
HTTP/1.1 blocks at the application layer, because the RFC requires answering in order. A slow response holds back responses that are already ready.
HTTP/2 fixed that and met the same problem one layer down. Streams really do interleave, but they all still travel over one TCP connection, and TCP does not know there are streams inside. Lose one packet and TCP holds back everything that came after it.
RFC 9114 states the effect plainly: "a lost or reordered packet causes all active transactions to experience a stall regardless of whether that transaction was directly impacted by the lost packet".
HTTP/3 fixed it by abandoning TCP, moving to QUIC over UDP, which handles reliability per stream. Streams unrelated to the lost packet keep going.
The general lesson is that fixing a problem at an upper layer while the layer below still thinks the old way always works only halfway.
Caching
Caching gives the most result per unit of effort of any mechanism here, and it is also the most often misconfigured.
It can be measured against this very site. The site's CSS file declares its cache-control like this.
$ curl -sI --http2 https://netkubelab.com/assets/www.css
HTTP/2 200
content-type: text/css; charset=utf-8
cache-control: public, max-age=14400, must-revalidate
etag: W/"4888fd76a5dedc18b63d5a9b24a08797"
Fresh and stale
RFC 9111 defines a fresh response as one whose age has not yet exceeded its freshness lifetime, and a stale one as where it has.
max-age=14400 is four hours. Within that window a browser takes it from the cache directly — without leaving the machine even once.
Past four hours it does not mean fetch again. It means ask first, and the tool for asking is the etag.
$ ET='W/"4888fd76a5dedc18b63d5a9b24a08797"'
$ curl -sI -H "If-None-Match: $ET" \
https://netkubelab.com/assets/www.css
HTTP/2 304
304 means what you have is still good, and its effect is measurable in bytes.
without etag 14380 bytes
with etag 0 bytes
Fourteen thousand bytes down to zero, while still being certain the file has not changed.
The W/ in front of the etag marks it a weak validator: it guarantees the contents are equivalent, not identical byte for byte, which is quite enough for deciding whether to fetch again.
no-cache and no-store
These two are swapped more often than anything else in the protocol, and RFC 9111 defines them very differently.
max-age=N fresh for N seconds
no-cache may be stored, but must be checked before every use
no-store must not be stored at all
must-revalidate once stale, do not reuse until validation succeeds
private a shared cache must not store it
public may be stored even where it normally would not be
no-cache does not mean do not cache. The RFC says it "MUST NOT be used to satisfy any other request without forwarding it for validation" — store it, but ask before every use, which still saves a great deal of bandwidth when the answer is a 304.
no-store is the one that means do not cache. The RFC says "a cache MUST NOT store any part of either the immediate request or the response", and this is the value that belongs on a page carrying personal data, not no-cache.
Where it lies to you
"GET changes nothing." The RFC says it should not, but nothing enforces it. A server can be coded so GET deletes data — and then a crawler following every link deletes everything, which has happened in reality more than once.
"200 means it worked." It means the HTTP request worked. The content inside may be an error message. A great many APIs answer 200 with a body saying the operation failed, which blinds everything that looks only at status codes.
"HTTPS means it is safe." It means the path between browser and server is encrypted and the server's identity is verified. It says nothing whatsoever about what that server does with your data afterwards.
"HTTP/2 is always faster." No. On a lossy network HTTP/2 can be slower than HTTP/1.1 with several connections open, because one stalled connection stops everything, while several connections lose only one.
"Setting no-cache is safe." No. The data is still written to disk. The value you want is no-store.
Worked examples from real work
Case 1 — the same file downloads every time although it never changes
A page is slow on every visit although its images and CSS have not been touched in months.
How to read it. Look at the response headers for anything a cache can act on.
$ curl -sI https://example.com/assets/app.css \
| grep -iE 'cache|etag|last'
If nothing comes back at all, that is the answer. No Cache-Control, no ETag, no Last-Modified leaves a cache with no basis for any decision, and the safest default is to fetch again every time.
Adding an ETag alone already turns a full download into a 304. Adding a max-age after that removes even the round trip to ask.
Not yet proved. We know the server says nothing, but not whether it was never configured or something in between stripped it. Test by going straight to the origin server and comparing against the normal path.
Case 2 — a response cut short, or hanging entirely
Some users get incomplete content, some hang until timeout, and it happens only with long responses.
How to read it. Compare the declared value against the bytes actually received.
$ curl -sS -o /dev/null -D - -w 'downloaded %{size_download}\n' \
https://example.com/api/report
Content-Length: 52418
downloaded 52418
If those two numbers disagree the problem is clear. A recipient that has not counted to the declared number keeps waiting, until timeout or until the connection closes — which is the source of both the hang and the truncation.
The common cause is something modifying the content in transit — a compressor, a script injector — without adjusting Content-Length to match.
Not yet proved. A mismatch says somebody modified it, not who. Test layer by layer outward from the origin.
Case 3 — one click, two orders
Users report placing one order and receiving two, and only when the network is slow.
How to read it. This is exactly the case the RFC warns about in the section on idempotence.
The request is a POST, which is not idempotent. When the response does not arrive within the timeout, the caller retries. But the first request may have arrived and been processed already, with only the response lost.
The point to understand is that this cannot be fixed on the sending side, because the sender has no information that separates the two cases. It has to be fixed on the receiving side, by having the sender attach a unique reference and the receiver reject one it has already seen.
That is giving POST idempotent behaviour by application-level agreement, because the protocol cannot supply it.
Not yet proved. We assume it is a retry. Confirm from the logs how far apart the two requests arrived and whether their bodies are byte-for-byte identical.
Case 4 — is HTTP/2 actually on?
HTTP/2 is configured, but it is not certain it is being used.
How to read it. Force each version and look at what differs.
$ curl -sI --http1.1 https://netkubelab.com/ | head -4
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
$ curl -sI --http2 https://netkubelab.com/assets/www.css | head -3
HTTP/2 200
content-type: text/css; charset=utf-8
Two differences stand out. The first line has no OK after it in version two, because the reason phrase was removed. And the field names are entirely lowercase, as RFC 9113 requires.
The third certain tell is the missing Transfer-Encoding, a field HTTP/2 forbids.
Not yet proved. curl negotiating HTTP/2 does not mean a real user's browser will. Version negotiation happens during the TLS handshake and depends on what both ends support.
References
The current standards
- RFC 9110 — HTTP Semantics June 2022, Standards Track, replacing nine older documents — the source for statelessness, the safe, idempotent and cacheable properties, and the five status classes
- RFC 9112 — HTTP/1.1 June 2022 — the grammar of the first line, the eight rules for finding the end of a body, and the warning about request smuggling
- RFC 9111 — HTTP Caching June 2022 — fresh against stale, and what no-cache and no-store really mean
- RFC 9113 — HTTP/2 June 2022 — the nine-byte frame, the definition of a stream, the lowercase requirement, and the list of forbidden fields
- RFC 9114 — HTTP/3 June 2022 — the clearest statement of what head-of-line blocking costs
Where it started
- RFC 1945 — Hypertext Transfer Protocol HTTP/1.0 May 1996, Berners-Lee, Fielding and Frystyk — the source of "generic, stateless, object-oriented" and of one request per connection
What is on the machine
curl -sI --http1.1andcurl -sI --http2against this very site, showing the difference in field-name case and theTransfer-Encodingthat disappears in version twocurl -H 'If-None-Match: ...'returning a 304, with the transferred size falling from 14,380 bytes to zero
Related reading here
- TCP and UDP — the stream HTTP runs on, and the source of the head-of-line blocking HTTP/2 could not fix
- DNS — the step before, turning a name into an address
- Ping — a tool that answers a different question from the one HTTP answers