HTTP is just text

COMMIT536605dHEAD → main
PUBLISHEDNov 16, 20196y ago
UPDATEDJul 25, 20265d ago
READING18 min3,701 words
#networking#http·by santiago toscanini

Every program I've ever written talks HTTP, and for years I'd never seen one of its requests. The abstraction is genuinely good: you pass a URL to a library, you get an object back, and the bytes stay somebody else's problem. Then something breaks below the library — a proxy rewrites a header, a body arrives truncated, a request that works in curl fails in code — and you find out you've been trusting a layer you can't read. So: what exactly is an HTTP request, byte by byte, and what does a server do with one?

The answer fits in your head, and I mean that literally. HTTP/1.1 is lines of text over a TCP socket. No binary header, no length-prefixed frames, no handshake beyond TCP's own. You could type a legal request by hand into a raw socket, and people routinely do. In November 2019 I wrote py-http to prove that to myself: a 260-line Python server that speaks the protocol with nothing but socket, threading, and string splits. It's the guide for this post. Everything below is verified twice: against the RFCs — today that's RFC 9110 for what the words mean and RFC 9112 for how HTTP/1.1 writes them down, the 2022 pair that retired the 7230 series I actually had open in 2019, which had itself retired 2616 — and against real traffic, captured with curl, nc, and a wiretap on py-http. And the figures aren't drawings: this page carries its own HTTP parser and response builder, written in TypeScript for this post, and every figure renders whatever they actually produce.

01 · Anatomy of a request

Here is a complete, real HTTP request: the exact 87 bytes curl emits for curl http://localhost:4333/echo/hello. I captured them by pointing curl at a fake server (nc -l writing to a file) and checking the result with xxd. The invisible bytes are the whole point, so this figure makes them visible: ␍␊ is the two-byte CRLF sequence, ␠ is a space. Hover anything:

Three parts, in a fixed shape. The request line: a method, one space, a target, one space, a version, CRLF. The grammar means exactly one space; that's the entire field separator mechanism, which is why a target can never contain one. The header fields: one per line, name: value, each line ended by CRLF. And then the strangest and most important line in the protocol: an empty one. Two bytes, ␍␊, in a stream position where a header name would start. Four bytes in a row, ␍␊␍␊, and the header section is over.

That's all the structure there is. No length prefix anywhere in sight, no frame type, no version negotiation dance. The framing IS the text: lines, and one blank line. Every binary protocol you'll meet does the opposite: it puts a length up front, so the receiver knows exactly how many bytes to read before it reads any of them. HTTP/1.1 chose the other ancient tradition, the one SMTP and FTP follow: delimiters, and all the parsing subtlety that comes with them.

02 · Type one by hand

That claim — you could type a request into a socket yourself — is worth cashing, because in 2026 it sounds false. Port 80 is not as dead as people assume (both example.com and google.com will still hand you a page in the clear over plain TCP, which surprised me), but anything with a login on it is HTTPS-only, and for sites on the HSTS preload list your browser refuses to even try. The good news is that TLS doesn't change a single byte of the grammar above. It wraps the same text in an encrypted tunnel, and openssl s_client is the modern nc. Here is a complete web page fetched from a real production server by typing at it. The four lines I sent are the whole request:

$ printf 'GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' \
    | openssl s_client -quiet -connect example.com:443
HTTP/1.1 200 OK
Date: Sat, 25 Jul 2026 04:12:14 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: close
Server: cloudflare
allow: GET, HEAD
Age: 6840
cf-cache-status: HIT

22f␍␊<!doctype html>...

Now delete one line and send it again. Not the method, not the target: the Host. The connection is identical, the target is identical, the server is the same machine at the same IP, and the answer changes completely — 400 Bad Request, because HTTP/1.1 makes that one field mandatory and a server that doesn't get it is required to refuse (RFC 9112 §3.2). Put it back but lie in it, Host: nope.invalid, and you get a third answer: 403 Forbidden. Same bytes on the wire except one line, three different responses.

That's virtual hosting, and it's the single change that defines HTTP/1.1 over 1.0. The IP address gets you to a machine; the Host line tells that machine which of the thousand sites it serves you actually want. Every shared host, every CDN, every cloud load balancer on earth depends on this field, which is also why the target stays in origin-form — a path, no scheme, no hostname. The host didn't disappear, it moved into a field of its own so that proxies could route on it. Keep that in mind for the last section, where a newer version of HTTP renames it :authority and pretends it invented something.

03 · Method, target, and what they promise

The other two fields carry more meaning than they look like they do. A method is just a case-sensitive token — GET is a method, get is an unknown one, and a server that receives it should answer 501, not run your handler. But the vocabulary comes with promises attached. GET and HEAD are safe: they're read-only requests, which is the license every crawler, prefetcher and browser preview takes to fire them off without being asked. GET, HEAD, PUT and DELETE are idempotent: doing them twice is the same as doing them once, which is the license a client library takes to silently retry them after a timeout. POST is neither, and that is exactly why your browser asks "resubmit this form?" and why a flaky checkout can charge you twice. None of this is enforced by the protocol. They're contracts you're expected to honour, and everything above you assumes you did.

A server that doesn't support a method on a resource is supposed to say so with a 405 and an Allow field listing what it does support — RFC 9110 §15.5.6 makes that field mandatory. Here is the very same example.com refusing a POST, and quietly not bothering:

$ curl -s -o /dev/null -D - -X POST http://example.com/
HTTP/1.1 405 Method Not Allowed
Server: cloudflare
Content-Type: text/html
                            ← no Allow field anywhere in the response

$ curl -s -o /dev/null -D - http://example.com/
HTTP/1.1 200 OK
allow: GET, HEAD            ← it knows. It sends it on the 200 instead.

That's worth seeing early, because it's what the rest of your career looks like. The RFC says MUST, a very large deployment says no, and your code has to cope with both.

The target hides a different subtlety. Section 01 pointed out that it can never contain a space, since one space is the entire field separator. But paths with spaces obviously exist, so where do they go? They get percent-encoded: %20, the byte value written as % plus two hex digits, and the same escape hatch covers every other character that would confuse a parser — ? starting the query string, # that never leaves the browser at all, and any byte outside ASCII. GET /files/my note.txt HTTP/1.1 isn't a slightly wrong request; it's a request line with four fields instead of three, so it stops being a request line at all. The lab in the next section has that as a button, and the parser's complaint is the honest one: it counted four.

04 · Break it yourself

Reading a format tells you half of it. The other half is what happens at the boundaries, and for that you need to hurt it a little. This is the page's parser, live: it takes the bytes from the textarea (your line breaks become the line ending selected below), runs the real RFC 9112 request grammar over them, and tells you exactly where it stands. The preset buttons stage the classic injuries.

Watch for the third verdict. A parser reading from a socket has three possible answers, not two: complete, rejected, and not yet. That last one is the whole reason network parsing is hard, so hunt for it as you break things below.

The not yet state is the one nobody draws. Delete the blank line and nothing "fails": the parser has no way to know whether more headers are coming, so a real server just blocks in recv(), waiting for bytes that never arrive, until a timeout kills the connection. Same story if Content-Length promises 50 bytes and you sent 15: the server isn't confused, it's patient. The difference between "malformed" and "still in flight" is invisible from inside the stream, and that asymmetry shapes every network protocol you'll ever meet.

The outright errors are worth your time too. Delete a single CRLF and the Host header gets glued onto the request line, which now splits into four space-separated words instead of three, so it stops being a request line at all. Put a space before a colon and you hit one of the RFC's bluntest sentences: a server MUST reject it with 400, because parsers disagreeing about where a header name ends is exactly how request smuggling attacks are built. And the é scenario makes the point sharpest: fifteen characters, sixteen bytes, and Content-Length counts bytes. The last byte of your body quietly becomes the first byte of the next request. When a complete request leaves bytes over, the lab lets you parse the leftover as its own request, which is that attack in miniature.

05 · Anatomy of a response

The server answers in the same dialect, with one substitution: the request line becomes a status line. Version, a three-digit code, and a phrase for the humans:

The code's first digit is the actual information channel: 2xx it worked, 3xx go elsewhere, 4xx your fault, 5xx my fault. Only the code is load-bearing. The reason phrase is decoration, and the proof is that RFC 9112 lets a server send an empty one, insisting only that the space before it stay put; flip the builder in the next section to empty-reason mode and you'll watch 200 sit there with nothing after it, still perfectly legal. Everything past the blank line is body, and the same question from the request side comes back sharper: the response body is the payload the whole exercise exists to deliver, so where, exactly, does it end?

06 · Where the body ends

TCP hands you a stream of bytes with no boundaries in it. Somebody has to say where a message stops, and HTTP/1.1 has three honest answers, in strict priority order (RFC 9112 §6.3). Build a response and cycle through them; the framing bytes rearrange themselves live:

Content-Length is the plain one: declare the body's size in bytes, then send exactly that many. The receiver counts down and stops. Its weakness is that you have to know the size before the first header byte leaves, which is fine for a file and hopeless for anything generated while streaming.

Chunked fixes that. Declare Transfer-Encoding: chunked instead, and the body describes its own length as it goes: a hex size line, that many bytes, CRLF, repeat, and a 0-sized chunk to say "done". Those green hex prefixes are the length-prefix idea sneaking back in through the body: HTTP couldn't put a length on the whole message, so it puts one on each piece. Set the chunk size to 4 with an é in the body and watch a boundary land in the middle of a character; chunks split bytes, not text, and the receiver just concatenates and decodes at the end. Real chunked traffic looks exactly like the figure. Here's production HTTP/1.1 from Google's homepage, captured with curl --raw, first chunk and last bytes:

$ curl -s --raw --http1.1 -H 'Accept-Encoding: identity' -D - https://www.google.com/
HTTP/1.1 200 OK
Transfer-Encoding: chunked

ad0␍␊<!doctype html><html itemscope="" ...   ← 0xad0 = 2768 bytes follow
...
</body></html>␍␊0␍␊␍␊                         ← last chunk: size 0, then the final empty line

That capture quietly admits something, though: I passed -H 'Accept-Encoding: identity' to make it readable. Left alone, curl asks for compression and Google obliges, and what arrives is gzip — the same page in 27,327 bytes instead of 80,635, and unreadable in a terminal. That's Content-Encoding, and it's a different axis from everything else in this section: encoding is about what the bytes are, framing is about where they stop. Content-Length always counts the compressed bytes, the ones actually on the wire, because framing is the receiver's problem long before decompression becomes anybody's.

Close is the fossil. No length, no chunks: the body ends when the server hangs up. HTTP/1.0 lived this way, and responses may still legally do it, but it can't tell "finished" from "crashed halfway", and it burns the connection.

There's a fourth rule that outranks all three, and it's a rule about absence: a few responses have no body no matter what their headers say. A 204 No Content, a 304 Not Modified, or any reply to a HEAD request ends at the blank line, and RFC 9112 §6.3 tells the receiver to ignore any Content-Length or Transfer-Encoding it finds. Switch the builder above to 304, or leave it at 200 and set it to answer a HEAD, and watch what happens: the head stays exactly as it was, Content-Length still announcing a body's worth of bytes, and then nothing comes. That phantom number is the point of HEAD — you learn a file's size and type without paying for it. 204 is stricter still: RFC 9110 §8.6 forbids Content-Length there outright, and you'll see the field vanish from the head when you pick that code.

The priority order exists because these mechanisms can contradict each other, and a receiver must never guess. If Transfer-Encoding and Content-Length both appear, chunked wins and the RFC tells the server to treat the message as a likely smuggling attempt. If two Content-Lengths disagree, the message is unrecoverable, full stop. Framing is the one place where HTTP is completely humorless, and the lab in section 04 shows you why: whatever lies past the framed end of one message gets parsed as the beginning of the next.

py-http, being 260 lines, supports exactly one of the three: Content-Length. It also has a bug I refuse to fix because it demonstrates the rule so well. Its response builder appends a decorative CRLF after the body, plus one more for luck, so /echo/hello puts 4 bytes on the wire beyond the 5 it declared. Here's real curl noticing, and then not caring:

$ curl -sv http://localhost:4221/echo/hello
< Content-Length: 5
* Excess found writing body: excess = 4, size = 5, maxdownload = 5, bytecount = 5
hello

curl read 5 bytes, exactly as promised, and discarded the rest. Content-Length is the contract; the socket is just the pipe it travels through.

07 · Asking for something you already have

The fastest response is the one that carries nothing, and a 304 is HTTP's way of sending you the same answer twice while only paying for it once. It's also the most useful thing in the protocol that almost nobody reads the mechanics of, so here they are.

A server can hand you a resource with a label attached: ETag: "bf64f2ea…", an opaque string that identifies this exact version of these exact bytes. Opaque is the operative word. It might be a hash, a revision number, an inode and a timestamp glued together; the client never inspects it, it just keeps it. Next time it wants the same thing, it quotes the label back in an If-None-Match field, which turns a plain GET into a question: send me the body only if it isn't this one. Here's that round trip against py-http's own README, which GitHub happens to serve with a strong tag:

$ curl -s -D - https://raw.githubusercontent.com/santiagotoscanini/py-http/master/README.md
HTTP/1.1 200 OK
Content-Length: 1177
Cache-Control: max-age=300
ETag: "bf64f2ea430f303312aa33735c8866ff2e52eaebaf04ef868fc2beec9ce014ee"

$ curl -s -D - -H 'If-None-Match: "bf64f2ea…"' https://raw.githubusercontent.com/.../README.md
HTTP/1.1 304 Not Modified
Cache-Control: max-age=300
ETag: "bf64f2ea430f303312aa33735c8866ff2e52eaebaf04ef868fc2beec9ce014ee"

1177 bytes, then zero, for the same answer. And notice which rule made that possible: the 304 is one of the bodyless responses from the last section, so it ends at the blank line and the validators are all it needs to carry. The figure below does that live, against a small endpoint on this site — a cold GET, then the same GET with your tag attached, and then the document changed underneath you so the identical conditional request has to come back 200 with a new one:

Two things that trip people up, both visible above. First, Cache-Control: no-cache does not mean "don't cache this". It means "keep it, but ask me before you reuse it" — exactly the conversation the figure is having. The directive that forbids storing a copy is no-store, and they get confused for each other constantly. Second, freshness and validation are two different mechanisms: max-age=300 lets a client skip the round trip entirely for five minutes, no questions asked, while ETag covers what happens after that — you still have to ask, but the answer can be cheap. The older validator, Last-Modified with If-Modified-Since, does the same job with a timestamp, which is worse only because it can't tell apart two edits in the same second.

This is also where HTTP stops being a machine for moving bytes and starts being a machine for not moving them. Every CDN edge, every browser back button, every npm install that finishes suspiciously fast, is this exchange happening somewhere between you and the origin.

08 · What a server actually does

Strip away every framework and a web server is a loop you can hold in one hand: accept a connection, read bytes, parse them, pick a handler, write bytes back. py-http does each step in the most literal way available, so it makes a good specimen. Below, its real code lines serve two overlapping connections; the bytes toggle shows the actual traffic, stray CRLFs included:

The concurrency story is the honest headline here. py-http spends almost all of its existence blocked: the main thread inside accept(), waiting for the kernel to hand it a connection, and each worker thread inside recv(), waiting for bytes. One thread per connection was the standard answer for decades, and it still works: the main loop stays free, so client B gets picked up while client A's request is mid-parse. It just doesn't scale gracefully, ten thousand connections means ten thousand stacks, which is the road that led to event loops, thread pools, and everything nginx.

Two py-http shortcuts are worth naming, because each one marks a real protocol obligation. It reads at most recv(1024) bytes, once: a real server loops, reading until the framing says the message is complete, which is the entire reason framing exists. And it closes the connection after every response, HTTP/1.0 style, while real HTTP/1.1 defaults to keeping it open (RFC 9112 §9.3). That default is real and observable: I opened one socket to a production server, wrote two requests into it, and got two responses back on the same connection. Connection: close is how either side politely opts out.

09 · The request you just sent

Everything so far analyzed requests I made. This figure would rather have yours. The button below runs a same-origin fetch() to /api/http-echo, a tiny route handler I added to this site that reports a request exactly as it sees one: method, target, and every header field, values included (cookies get redacted server-side before anything is returned). Whatever arrives is rendered in the anatomy layout from section 01. You can smuggle a header of your own along, too:

Read the figure's fine print, because its honesty is the lesson. In production this site sits behind Vercel's edge, and your browser almost certainly spoke HTTP/2 or HTTP/3 to it, not HTTP/1.1; the strip above the bytes shows which, as reported by your own browser's resource timing. The edge terminates that connection, and what my handler receives is a retelling: field names forced to lowercase (HTTP/2 requires that on the wire, and the runtime keeps it), original order gone, the version unknowable, and a crop of x-vercel-* and x-forwarded-* headers your browser never sent. The figure labels itself a reconstruction of the handler's view because that's what it is; the original wire bytes died at the edge, as they do for nearly every request on the modern web.

What survives the trip untouched is the vocabulary: your method, your target, your headers' names and values (case aside), and the sec-fetch-* metadata your browser volunteered about how the request came to be.

One field in that list deserves its own paragraph, and it's the one whose value never reaches you: cookie. HTTP is stateless — nothing in anything you've read carries over from one message to the next, and a server handed your second request has no idea it ever saw your first. That is a design decision, not an oversight; it's what lets any of a hundred machines behind a load balancer answer you. Sessions, logins, shopping carts and every "remember me" checkbox are that gap being papered over by a header the server issued with Set-Cookie and your browser now attaches, unprompted, to every request it sends that site, forever, including the one the figure above just made. The protocol stayed stateless. We built the state on top and called it the web.

10 · New framing, same sentences

HTTP/2 (2015) and HTTP/3 (still an unfinished QUIC draft the month I wrote py-http, RFC 9114 since 2022) look nothing like what you've been reading. Binary frames instead of text lines, multiplexed streams instead of one message at a time, header compression, and in HTTP/3's case a different transport entirely (QUIC over UDP). Here's curl speaking HTTP/2 to Google; the request line and Host header are gone, replaced by pseudo-header fields on a numbered stream:

$ curl -sv --http2 https://www.google.com/ -o /dev/null
* ALPN: server accepted h2
* [HTTP/2] [1] OPENED stream for https://www.google.com/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: www.google.com]
* [HTTP/2] [1] [:path: /]

Different grammar, same sentences. :method: GET is the same GET with the same semantics; :path is the request target; :authority is Host wearing its real name. Status codes, methods, header meanings, caching rules: all of that lives in RFC 9110, which was deliberately split out so that every protocol version refers to one shared definition of what the words mean. The 2022 reorganization made the layering explicit: RFC 9110 is the language, and RFC 9112, 9113, and 9114 are three ways of writing it down. Learn the vocabulary once, with the version where you can read it off the wire by eye, and the newer framings become an encoding detail.

The layers above stack just as cleanly. TCP carries bytes with no boundaries; HTTP cuts them into messages; and whatever sits inside the body is somebody else's grammar entirely — JSON, HTML, a protobuf, a tarball, a version-control protocol. HTTP doesn't look inside, which is exactly why everything ended up riding on it. Every API you've ever called works this way, and now you've read the layer everyone else abstracts over.

py-http is the toy that taught me where the sharp edges are: github.com/santiagotoscanini/py-http, 260 lines, one afternoon to read. Between it, the lab above, and curl -v, the web's lingua franca stops being infrastructure and becomes text you can read. Which it always was.

$git log --oneline public/posts/http-by-hand/
536605dNeural networks from scratch: a third post on pixels and vectorisationtoday
© 2026 · v2.0 · santiago toscanini