Back to Articles
HTTPNetworkingWebComputer Science

HTTP, Properly This Time

A complete HTTP request is a verb, a path, some Key: Value lines and a blank line — you can type it by hand into a socket. Everything on top of that, from persistent connections and multiplexing to CORS, caching and TLS, is thirty years of cleverness wrapped around a shape that never changed. A crash course, written up properly.

August 29, 202655 min read

Why I Sat Through a Crash Course

I use HTTP every single day and I had never actually learned it. Not properly. I picked it up sideways — from framework docs, from fetch calls, from whichever Stack Overflow answer made the CORS error go away. That works right up until something behaves strangely and you realise your mental model is a collection of habits with no shape.

So I sat down with an HTTP crash course on YouTube and took notes. Writing those notes up into this post is where it got uncomfortable, because explaining a thing is the fastest way to find out which parts you only thought you understood. Several of my notes turned out to be wrong. I have fixed them here and flagged the ones worth flagging.

The thing that reframed everything for me was how small the protocol actually is. Here is a complete HTTP request and response, typed by hand into a raw TCP socket:

bash
$ printf 'GET /index.html HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' \
    | nc example.com 80

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 1256
Date: Fri, 29 Aug 2026 08:14:02 GMT

<!doctype html>
<html>...

That is the entire protocol. A verb, a path, a version, some Key: Value lines, a blank line, and optionally a body. No handshake of its own, no binary format, no framing. You can type it with your fingers. A human can read the wire.

HTTP is just text over a socket. Everything else — persistent connections, multiplexing, headers, CORS, caching, TLS — is thirty years of cleverness layered on top of a shape that never changed.

That is the spine of this post, and it is worth holding onto, because the rest of HTTP is genuinely large. The layers exist for good reasons and each one solves a real problem, but none of them replaced the text. They wrapped it.

By the end of this you should be able to read a raw request and response line by line, explain why HTTP/2 exists and what HTTP/3 fixed that HTTP/2 could not, say precisely what a preflight OPTIONS is asking for, know why a 304 is the best response your server can send, and describe exactly what TLS hides and what it leaves in the open. No production experience assumed. Every term gets defined the first time it shows up.

The Server That Forgets You

HTTP is a client-server protocol. One side — a browser, a mobile app, curl, another service — sends a request. The other side sends back a response. The client always speaks first. A server never spontaneously calls you; it answers, and then it is done.

And here is the part people skip past too quickly: HTTP is stateless. Every request is independent. The server keeps no memory of the request that came before it. As far as the protocol is concerned, each message arrives from a stranger.

That sounds like a limitation. It is the single design decision that let the web scale.

Look at what statelessness forces. If you are logged in and you load two pages, both requests must carry your identity themselves:

http
GET /api/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Accept: application/json

GET /api/orders/8812 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Accept: application/json

The second request repeats everything. It does not say "same user as before" — there is no before. It re-proves who it is, from scratch, every time. Cookies work the same way: Set-Cookie hands the client a value, and the client sends it back on every subsequent request. The state lives in the client, or in a database the server can look up. It never lives in the protocol.

That is annoying for about five minutes and then it pays for itself forever, because it means any server can answer any request:

text
Request 1  ->  load balancer  ->  server A   (validates token, answers, forgets)
Request 2  ->  load balancer  ->  server C   (validates token, answers, forgets)
Request 3  ->  load balancer  ->  server B   (validates token, answers, forgets)

Server C catches fire mid-traffic.
Request 4  ->  load balancer  ->  server A   (validates token, answers, forgets)

Nobody noticed. No session was lost, because no session lived on server C.

Horizontal scaling — adding more machines instead of a bigger machine — is only easy because of this. If server C were holding your login in memory, the load balancer would have to keep sending you back to server C (this is called sticky sessions), and when server C dies it takes every user pinned to it down with it. Statelessness turns your server fleet into interchangeable parts.

The cost is redundancy on the wire. Every request re-sends its headers, its cookies, its auth token. On a page that pulls a hundred assets, that is a hundred copies of the same few kilobytes. Remember this — it comes back in HTTP/2, which spends real complexity fixing exactly this bill.

The Connection Underneath

HTTP does not move bytes. TCPdoes — the Transmission Control Protocol, the transport layer underneath, which turns the internet's unreliable packet delivery into an ordered, reliable stream of bytes between two machines. If a packet is lost, TCP notices and resends it. If packets arrive out of order, TCP reorders them before your application sees a single byte. HTTP just writes text into that stream and reads text back out.

Getting a TCP connection open costs a round trip. Three, technically — the three-way handshake:

text
Client                                Server
  |  ---------- SYN ------------->      |   "I want to talk"
  |  <------ SYN + ACK -----------      |   "Sure, I hear you"
  |  ---------- ACK ------------->      |   "Great, starting now"
  |                                     |
  |  ---- GET /index.html ------->      |   ... and only NOW can HTTP speak

On a link with 50 ms of latency, that handshake costs about 100 ms before a single byte of HTTP moves. Add TLS on top and it is worse. The entire history of HTTP versions is, more or less, the story of trying not to pay that bill over and over.

HTTP/1.0 (1996) paid it every time. One request, one response, connection closed. A page with thirty images meant thirty TCP handshakes. This was fine when a page was a document and disastrous the moment a page became an application.

HTTP/1.1 (1997) made connections persistent by default — the connection stays open and gets reused for the next request. That is what Connection: keep-alive means, and in 1.1 it is the default, so you mostly only see the opposite: Connection: close. You can watch the reuse happen:

bash
$ curl -v --http1.1 https://api.example.com/a https://api.example.com/b 2>&1 \
    | grep -E "Connected to|Re-using|GET /"

* Connected to api.example.com (93.184.216.34) port 443
> GET /a HTTP/1.1
* Re-using existing connection with host api.example.com
> GET /b HTTP/1.1

HTTP/1.1 also allowed pipelining — firing several requests down the connection without waiting for each response. It sounds like the fix. It is not, because the responses still have to come back in the order the requests were sent. One slow response holds up every response behind it, even if those are already finished and sitting in a buffer.

This is head-of-line blocking, and it is the through-line of this entire section. Name it once and you will see it everywhere:

text
One HTTP/1.1 connection, pipelined:

  -> GET /slow-report.json     (server takes 2000 ms)
  -> GET /logo.png             (ready in 5 ms)
  -> GET /app.css              (ready in 5 ms)

  <- ................................ 2000 ms ...... slow-report.json
  <- logo.png       (was ready at 5 ms. waited 2000 ms anyway.)
  <- app.css        (same.)

The queue is FIFO. One slow item at the head blocks everything behind it.

Pipelining was so unreliable in practice — broken proxies, buggy servers — that browsers disabled it. Their actual workaround was cruder: open roughly six parallel TCP connections per origin and spread requests across them. Six lanes instead of one. It works, and it is also why the "domain sharding" trick existed, where you served assets from static1.example.com and static2.example.com purely to get more connection slots.

HTTP/2 (2015) attacked this properly. Three changes matter:

1. It stopped being text. HTTP/2 is a binary protocol. Messages are split into frames — small typed chunks with a length, a type, and a stream identifier. You can no longer type it into nc. The semantics are unchanged though: same methods, same headers, same status codes. Only the encoding on the wire is different.

2. Multiplexing. Many independent streams share one TCP connection, and their frames interleave freely. Response B can finish before response A without blocking it. This kills head-of-line blocking at the HTTP layer, and it retires the six-connections hack in one move.

3. HPACK header compression. Remember the redundancy tax from statelessness? HPACK keeps a shared table of headers already sent on this connection, so the hundredth request can refer to Authorization and User-Agent by index instead of resending them in full.

HTTP/2 also shipped server push, where the server volunteers a resource before the browser asks. It sounded great and it is effectively dead: it pushed things browsers already had cached, it was hard to get right, and Chrome disabled it by default in version 106 with Firefox following. Do not build on it. The modern replacement is 103 Early Hints — the server sends an informational response saying "you will probably want these" and the browser decides.

Which leaves one problem, and it is not HTTP's fault at all. HTTP/2 put every stream on one TCP connection — and TCP guarantees ordered delivery of the whole stream. So if a single packet is lost, TCP will not hand any later bytes to the application until that packet is retransmitted. Even bytes belonging to completely unrelated streams.

HTTP/2 fixed head-of-line blocking at the HTTP layer and pushed it down one floor, into TCP. Multiplexing over a protocol that insists on total ordering means one lost packet stalls every stream at once — you did not remove the queue, you moved it somewhere you could no longer reach.

HTTP/3 (standardised 2022) fixes it by refusing to use TCP. It runs on QUIC, a transport built on UDP — the connectionless, unordered, no-guarantees protocol — with reliability and ordering rebuilt on top, per stream rather than per connection. Lose a packet belonging to stream 7 and only stream 7 waits. Streams 1 through 6 carry on.

QUIC gets two more things nearly for free. TLS is built in rather than layered on, so the transport and cryptographic handshakes merge into one round trip instead of two. And a connection is identified by a connection ID rather than by the IP-and-port four-tuple, so when your phone hops from Wi-Fi to mobile data, the connection survives instead of being torn down and rebuilt.

VersionYearConnection modelHead-of-line blocking
HTTP/1.01996New TCP connection per requestNot applicable — one request at a time
HTTP/1.11997Persistent connections; ~6 parallel per originYes, at the HTTP layer — pipelined responses must return in order
HTTP/22015One TCP connection, many multiplexed binary streamsFixed at the HTTP layer, still present at the TCP layer
HTTP/32022QUIC over UDP, independently ordered streamsGone — a lost packet stalls only its own stream

Worth noticing what did not change across all four rows. GET still means GET. 404 still means 404. Content-Type still means Content-Type. Twenty-six years of transport engineering, and the semantics on top are the same ones you can still type by hand.

Anatomy of a Request

Both messages — request and response — have the same four-part shape: a start line, some headers, a blank line, and an optional body. Once you see the shape you can read anything.

The request's start line is three fields separated by spaces: the method, the target, and the version.

http
POST /api/orders HTTP/1.1
Host: api.example.com
User-Agent: curl/8.7.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
Content-Length: 62
Accept: application/json

{"sku":"KB-87-BROWN","quantity":2,"shipping":"standard"}

Line by line. POST is the method — what you want done. /api/orders is the target, the path plus any query string. HTTP/1.1 is the version, so both sides agree on the rules.

Then headers, one per line, Name: value. Host is the one that is mandatory in HTTP/1.1 and it is worth knowing why: one IP address commonly serves hundreds of sites, so the server needs the requested hostname to decide which one you meant. That is virtual hosting, and without Host it is impossible. Header names are case-insensitive, so content-type and Content-Type are the same header.

Then a blank line. This is the only structural marker in the whole format — it says "headers are finished, everything after this is body." Every line ends with CRLF (carriage return plus line feed, \r\n), so the boundary is literally the four bytes \r\n\r\n.

Then the body — and note Content-Length: 62. The body is not self-delimiting. It is a stream of bytes, and the only reason the server knows where it ends is that you counted them.

The response is the same shape with a different start line — version, status code, reason phrase:

http
HTTP/1.1 201 Created
Date: Fri, 29 Aug 2026 08:14:03 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 87
Location: /api/orders/8812
Cache-Control: no-store

{"id":8812,"sku":"KB-87-BROWN","quantity":2,"status":"pending","total":15980}

The reason phrase — Created, Not Found, Internal Server Error — is for humans only. Nothing should ever parse it. It is advisory text, servers may write whatever they like there, and HTTP/2 dropped it entirely.

One honest caveat, since I said HTTP is text and then spent a section explaining that HTTP/2 is binary. Over HTTP/2 and HTTP/3, this exact byte layout is not what crosses the wire — headers are compressed into HPACK or QPACK and shipped as binary frames. But curl -vand your browser's network tab still show you this shape, because it is still the model. The text form is the language; the binary form is a more efficient accent.

Headers Are the Remote Control

The start line says what you want. The headers say everything else — who you are, what formats you can handle, what you already have cached, how long the answer stays fresh, what the browser is allowed to do with the result. Almost every feature in this post is implemented as a header. Learning HTTP is mostly learning headers.

Grouping them helps, as long as you know the grouping is a teaching device rather than a hard boundary — the modern specification does not carve headers into strict categories, and plenty of headers sit in more than one bucket. Still, these four questions cover most of what you will meet:

CategoryAnswers the questionExamples
RequestWho is asking, and what do they want?Host, Authorization, Cookie, Accept, User-Agent, Referer, Origin
ResponseWho answered, and what should you do next?Server, Set-Cookie, Location, Retry-After, WWW-Authenticate
RepresentationWhat is this body, and how is it packaged?Content-Type, Content-Length, Content-Encoding, Content-Language, ETag, Last-Modified
Connection / generalHow is this message being moved?Connection, Transfer-Encoding, Date, Cache-Control, Via

A few that repay knowing precisely. Content-Type is not decoration — it is how the other side decides whether to parse bytes as JSON, render them as HTML, or offer a download, and getting it wrong is the cause of a genuinely large share of "why is my API returning a string" confusion. Referer carries the page you came from and is famously misspelled in the standard itself, a typo from 1996 that is now permanent. Origin is the security-relevant cousin: just the scheme, host and port of the calling page, with no path, so it leaks less. It is the header the entire CORS section turns on.

Then there is a category that exists purely to tell the browser to be less permissive than it would be by default. These are security headers, and they are all instructions the server sends to protect the user viewing the page:

Custom headers are allowed and common — X-Request-Id for tracing a request across services is close to universal. The old convention of prefixing your own headers with X- was formally deprecated back in 2012, because too many X- headers became de facto standards and were then stuck with a prefix that says "experimental" forever. Nobody enforces this. You will see X- everywhere. But if you are inventing a header today, you do not need it.

Methods, and the Promise of Idempotency

The method is the verb. Six of them cover almost everything you will write:

GET retrieves a resource and should never modify anything. POST submits data, usually creating something new. PUT replaces a resource entirely with the body you send. PATCH modifies part of a resource. DELETE removes it. OPTIONS asks what is permitted for this resource without doing it — and it is the whole basis of the CORS preflight in the next section.

Two properties classify them, and my notes had these mashed together, which I suspect is common because the words feel similar. They are not.

Safe means the request does not change server state. It is read-only. You can send it a thousand times and the server is exactly as you found it.

Idempotent means sending the request N times has the same effect on server state as sending it once. It says nothing about whether state changes — only that repeating does not compound.

Every safe method is idempotent, because doing nothing repeatedly is still nothing. The reverse does not hold, and that gap is the interesting part: DELETE /orders/8812 definitely changes state, so it is not safe. But delete it once and it is gone; delete it four more times and it is still exactly gone. Idempotent.

MethodSafeIdempotentWhy
GETYesYesReads only. Repeating changes nothing.
HEADYesYesA GET that returns headers with no body. Used to check size or freshness cheaply.
OPTIONSYesYesAsks what is allowed. Answering the question does not act on the resource.
PUTNoYesReplaces the resource with your body. Send it five times, you get the same final resource.
DELETENoYesRemoves it. Once removed, removing again leaves the same state.
POSTNoNoCreates something new each time. Five POSTs, five orders.
PATCHNoNoNot idempotent in general — it depends entirely on what your patch document says.

The PATCH row deserves a sentence, because it is the one that trips people. A patch that says set status to shipped is idempotent in practice — apply it five times, same result. A patch that says increment quantity by 1 is very much not. The specification declines to promise idempotency for PATCH precisely because it cannot see inside your patch document. Design yours to be idempotent anyway; it costs nothing and it buys you the next paragraph.

A related trap in my notes: I had written that DELETE is not idempotent because the second call returns 404 instead of 204. That is a real observation and the wrong conclusion. Idempotency is a property of server state, not of the status code you get back. The resource is gone either way. The response differs; the world does not.

Now the payoff, because this is not taxonomy for its own sake. Consider a request that times out. Your client sent it. It got nothing back. What actually happened?

text
Client sends:  POST /api/orders   {"sku":"KB-87-BROWN","quantity":2}
Client waits...
Client times out after 30s.

Three possibilities, and from the client they are INDISTINGUISHABLE:

  (a) The request never reached the server.       -> order not created
  (b) The server processed it, response was lost.  -> order CREATED
  (c) The server is still processing it right now. -> order about to be created

Retry blindly and in case (b) you have just charged the customer twice.

This is the whole reason idempotency matters. If the request is idempotent, a retry is free — worst case you do the same thing twice and the second one lands on an unchanged world. If it is not, a retry is a gamble with your user's money.

Idempotency is not a classification exercise. It is the property that decides whether your retry is a recovery or a second bug.

For the operations that genuinely cannot be idempotent, the standard fix is an idempotency key: the client generates a unique identifier, sends it as a header, and the server records it. A second request carrying a key it has already seen gets the original response replayed instead of doing the work again. You are manufacturing the property the method does not give you.

http
POST /api/payments HTTP/1.1
Host: api.example.com
Idempotency-Key: 8f14e45f-ea0c-4b1e-9f2a-3d7c1b0a5e62
Content-Type: application/json

{"amount":15980,"currency":"INR","order_id":8812}

Which connects to something I wrote about from the other direction in the Byzantine Generals post, which ends on retry logic and exponential backoff. Those two ideas are halves of the same thing. Backoff decides when you are allowed to retry. Idempotency decides whether you are allowed to at all. Get the backoff right and the idempotency wrong and you have built a very well-paced duplicate-charge machine.

CORS Is Not Protecting Your Server

Every developer meets CORS the same way: something in the console, in red, about an origin not being allowed, and a strong suspicion that the backend is being difficult. For a long time I treated it as an obstacle rather than a design. It is worth ten minutes of actually understanding, because the mental model most of us carry is backwards.

Start with the thing CORS is an exception to. Browsers enforce the same-origin policy: JavaScript running on one origin cannot read responses from a different origin. An origin is exactly three things — scheme, host, port — and all three must match.

Compared against https://app.example.comSame origin?Why
https://app.example.com/ordersYesPath is irrelevant. Only scheme, host and port count.
http://app.example.comNoDifferent scheme — http is not https.
https://api.example.comNoDifferent host. A shared parent domain does not help.
https://app.example.com:8443NoDifferent port.

Why does the browser care? Because of the one thing that makes browsers different from curl: the browser holds your credentials and attaches them automatically. If you are logged into your bank and you open a page on some other site, and that page runs fetch("https://bank.example.com/api/balance"), your session cookie goes along for the ride. The same-origin policy is what stops that page from reading the answer.

CORS does not protect your server. Your server will cheerfully answer anyone — curl and Postman ignore CORS completely, because there is no user session to abuse. CORS protects the browser's user from a page they did not trust reading responses that were authorised with their credentials.

That single sentence reorganises everything. The rules are enforced client-side, by the browser, on behalf of the person sitting in front of it. The server is not defending itself; it is granting permission for the browser to relax. Which is why the whole mechanism is a set of response headers whose names all start with Access-Control-Allow.

There are two paths through it, and knowing which one you are on explains almost every confusing CORS error.

Simple requests go straight out. A request qualifies as simple if it is GET, HEAD or POST, uses only a small safelist of headers, and — if it is a POST — has a Content-Type of application/x-www-form-urlencoded, multipart/form-data, or text/plain. That safelist is deliberately the set of requests an HTML form could already make in 1995, so allowing them adds no new attack surface.

Here is the part that surprised me most. For a simple request, the request is sent and the server does the work. The browser only inspects the response afterwards, and if the Access-Control-Allow-Origin header does not permit the calling origin, it throws away the response and gives your JavaScript an error. The row was still inserted. CORS did not prevent the write; it prevented you from reading about it.

Preflighted requests are everything else — any other method, or a non-safelisted header, or the one that catches practically every real API: Content-Type: application/json. JSON is not on the safelist, which is why a normal REST call almost always triggers a preflight even though it looks utterly ordinary. Authorization is not safelisted either.

A preflight is a real, separate HTTP round trip that happens before the request you actually wanted, using OPTIONS. It is the browser asking permission. Here is the full sequence for a single PATCH.

Step 1 — the browser asks. Note that it does not send the body, or even really the request. It sends a description of what it is about to do.

http
OPTIONS /api/orders/8812 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: authorization,content-type

Step 2 — the server answers with permissions. And it answers with 204 No Content, because there is genuinely nothing to say: the entire answer is in the headers, and a body would be wasted bytes on a request the user never asked for.

http
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin

Step 3 — only now does the real request go. The browser sends Origin again, and the server must repeat its permission on the real response too. A preflight approval does not carry over.

http
PATCH /api/orders/8812 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
Content-Length: 24

{"status":"cancelled"}

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Content-Type: application/json

{"id":8812,"status":"cancelled"}

That is two round trips for one API call, which is why Access-Control-Max-Age exists: it tells the browser how many seconds it may reuse this approval for the same origin, method and header set without asking again. Set it and the preflight cost disappears for everything after the first call. There are ceilings, though, and they are lower than people expect — Chromium caps it at 7,200 seconds (two hours) and Firefox at 86,400 (24 hours), and if you omit the header entirely the browser default is a nearly useless 5 seconds. Asking for a year gets you two hours.

Two more rules that account for most remaining CORS pain. If you need cookies or HTTP auth to travel cross-origin, the client must opt in (credentials: "include") and the server must send Access-Control-Allow-Credentials: true — and in that mode the wildcard Access-Control-Allow-Origin: * is forbidden. You have to echo the exact origin back. Which leads directly to the second rule: once your response varies by the request's Origin, you must send Vary: Origin, or a cache in the middle will hand one origin's approval to a different origin.

And the thing to internalise: if you are ever tempted to reach for Access-Control-Allow-Origin: *on an authenticated API to make an error go away, notice what you are actually doing. You are not fixing your server. You are telling every browser in the world that any page may read your users' data.

Status Codes Are a Shared Vocabulary

The status code is a three-digit number, and the first digit is the whole category. If you remember only the five classes, you can make a sensible guess at any code you have never seen.

ClassMeaningWho is responsibleCommon members
1xxInformational — hold on, still goingNobody yet100 Continue, 101 Switching Protocols, 103 Early Hints
2xxSuccess — it workedNobody, it is fine200 OK, 201 Created, 204 No Content, 206 Partial Content
3xxRedirection — it lives elsewhere, or you already have itNobody301, 302, 304 Not Modified, 307, 308
4xxClient error — your request was wrongThe caller400, 401, 403, 404, 405, 409, 415, 429
5xxServer error — the request was fine, we brokeThe server500, 502, 503, 504

The 4xx / 5xx split is the most useful line in that table and the one most often blurred in real code. It is an assignment of blame. A 4xx says do not retry this unchanged, it will fail identically. A 5xx says this might work if you try again. Returning 500for a malformed request is not just untidy — it tells every client and monitoring system on the other side to retry a request that can never succeed, and it puts your own name on someone else's bug.

The ones worth knowing exactly:

201 Created should come with a Location header pointing at what you just made. 204 No Content means success with deliberately no body — the right answer to a DELETE, and to a CORS preflight. 206 Partial Content is the answer to a range request, which shows up again two sections from now.

401 versus 403 is the classic mix-up, and the reason is that 401 is misnamed. 401 Unauthorized actually means unauthenticated — I do not know who you are, log in and try again. 403 Forbidden means I know exactly who you are and you still may not do this. Sending a 401 to an authenticated user with the wrong role tells their client to go refresh a token that was never the problem, which is a genuinely nasty way to build a retry loop.

The redirects hide a trap that is worth its own paragraph. 301 and 308 are permanent; 302 and 307 are temporary. That is the axis everyone knows. The axis that actually bites is what happens to the method:

CodeDurationMethod on the follow-up request
301 Moved PermanentlyPermanentMay be changed from POST to GET — historical behaviour, still widespread
302 FoundTemporaryMay be changed from POST to GET — same historical wart
307 Temporary RedirectTemporaryGuaranteed preserved. POST stays a POST, body included.
308 Permanent RedirectPermanentGuaranteed preserved. POST stays a POST, body included.

The 301 and 302 behaviour is not what the original specification asked for — early browsers did it anyway, enough software came to depend on it, and the standard eventually documented the reality rather than fighting it. 307 and 308 exist entirely to give you the versions with no ambiguity. So if you are redirecting a form submission or an API call and you need the body to survive, use 307 or 308. Use 302 and you may discover your POST quietly arriving as a GET with nothing in it.

304 Not Modified is the odd one in the 3xx family — it is not really a redirect at all, it is the caching answer, and it gets the next section to itself.

On the client-error side: 400 for malformed syntax, 404 for not found, 405 Method Not Allowed when the path exists but the verb does not (and it is required to tell you what does, via an Allow header), 409 Conflict for a state clash like a duplicate signup, 415 Unsupported Media Type when the body is a format you do not accept, and 429 Too Many Requests for rate limiting — which should always carry Retry-After, because otherwise every client you just throttled will guess, and they will guess badly and all at once.

http
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json

{"error":"rate_limited","limit":100,"window":"1m"}

And on the server side, the three you will actually stare at during an outage: 502 Bad Gateway means the proxy reached your service and got garbage back, 503 Service Unavailable means the service is deliberately not accepting work right now, and 504 Gateway Timeout means the proxy gave up waiting. All three come from something in front of your application, which is a useful thing to know at 2 a.m. — a 502 is usually your app crashing, a 504 is usually your app being slow.

The Fastest Request Is the One You Never Make

Every optimisation in this post so far has been about making requests cheaper. Caching is the only one that makes them not happen. A response served from a local cache costs zero network round trips, zero server CPU, and zero bytes. Nothing else in HTTP competes with that.

The header that drives it is Cache-Control, and the very first thing to fix is a misreading I had been carrying.

no-cache does not mean "do not cache this." It means "cache it, but never serve it without asking me first." The directive that means do not keep a copy at all is no-store.

The directives worth knowing: max-age=N — this is fresh for N seconds, serve it from cache without asking. no-cache — store it, but revalidate every time. no-store — never write it down, for anything with a bank balance in it. private — only the user's own browser may cache this, not the CDN in between. public — shared caches may keep it. immutable — this content will never change at this URL, so do not even revalidate on a refresh.

Now the interesting half. max-age handles the easy case, but it forces you to guess a number, and the guess is always wrong in one direction. Too long and users see stale content. Too short and you are back to full downloads.

Validation is the way out. The idea: when the cached copy expires, do not re-download it — ask whether it changed. If it did not, the server says so in a response with no body at all. The tool is the ETag, an entity tag: an opaque identifier the server attaches to a specific version of a resource, usually a hash of the content.

Round trip one. Nothing cached. Full response, plus an ETag.

http
GET /assets/app.css HTTP/1.1
Host: cdn.example.com

HTTP/1.1 200 OK
Content-Type: text/css; charset=utf-8
Content-Length: 84213
Cache-Control: public, max-age=3600
ETag: "9f2b1c4e7a3d"
Last-Modified: Wed, 27 Aug 2026 11:02:44 GMT

/* ...84 KB of CSS... */

Round trip two, an hour later. The cached copy is stale, so the browser revalidates — it sends the ETag it holds back in If-None-Match, which reads as "give me this file, but only if its tag is not the one I already have."

http
GET /assets/app.css HTTP/1.1
Host: cdn.example.com
If-None-Match: "9f2b1c4e7a3d"

HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=3600
ETag: "9f2b1c4e7a3d"

(no body — the response ends here)

Zero bytes of CSS crossed the wire. The browser keeps the copy it had, resets its freshness clock, and moves on. You still paid one round trip, but you paid it in a couple of hundred bytes instead of 84 kilobytes — and on a page with sixty assets that is the difference between a reload feeling instant and feeling like a reload.

There is an older, weaker version of the same dance using dates rather than hashes: the server sends Last-Modified, the client sends it back as If-Modified-Since, and the server compares timestamps. It works, and it is less precise — timestamps have one-second resolution and a rebuild can change a file's date without changing a byte. ETags win when both are present.

You may also see an ETag written as W/"9f2b1c4e7a3d". The W/ marks it weak: this version is semantically equivalent, not byte-identical. Useful when a response is regenerated with a different timestamp inside it but the same meaning.

Which brings you to the strategy that actually gets used in production, and it sidesteps the whole freshness problem by changing the question. Instead of asking "how long is this file good for," put a hash of the content in the filename:

http
# Fingerprinted asset — the URL changes whenever the content does.
GET /assets/app.4f3c9e1b.css

Cache-Control: public, max-age=31536000, immutable
# One year. Never revalidate. It is safe because a new build
# produces app.7a2d5f80.css, which is a different URL entirely.

# The HTML that references it — never cached, always checked.
GET /index.html

Cache-Control: no-cache
ETag: "a1b2c3d4"

The HTML is tiny and always revalidated, so a deploy is visible immediately. The assets are enormous and cached for a year, and they can be, because a changed asset is a changed URL. Every modern bundler does this by default, and now you know what the hash in the filename is for.

Negotiating the Format

One URL can have several legitimate representations. The same resource might be available as JSON or XML, in English or Hindi, compressed or raw. Rather than inventing a different URL for each combination, HTTP lets the client state its preferences and the server choose. This is content negotiation, and it runs on the Accept family of request headers.

http
GET /api/orders/8812 HTTP/1.1
Host: api.example.com
Accept: application/json;q=1.0, application/xml;q=0.8, */*;q=0.1
Accept-Language: en-IN, en;q=0.9, hi;q=0.7
Accept-Encoding: gzip, br, zstd

The q is a quality value — a preference weight from 0 to 1, defaulting to 1 when omitted. That first line reads: I would like JSON, I will take XML, and if you have neither I will accept literally anything rather than fail. The server picks the best it can offer and reports its choice in the response, which is the part people forget: negotiation is a request for a preference, not a command.

http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Language: en-IN
Content-Encoding: gzip
Vary: Accept, Accept-Language, Accept-Encoding
Content-Length: 1204

Vary is not optional here and it is the most-skipped header in this whole area. It tells every cache between you and the client: this response depends on these request headers, so key your cache on them too. Leave it out and a CDN will happily serve the gzipped English version to a client that asked for plain Hindi. If your response varies by anything, say so.

Accept-Encoding is the one with the biggest practical payoff, because text compresses absurdly well. JSON and HTML are enormously repetitive — the same keys, the same tags, over and over — which is exactly the pattern compression eats:

bash
$ curl -sI https://api.example.com/orders | grep -i content-length
content-length: 84213

$ curl -sI -H 'Accept-Encoding: gzip' https://api.example.com/orders \
    | grep -iE 'content-length|content-encoding'
content-encoding: gzip
content-length: 9847

# 84 KB -> 9.8 KB. One header. No code changed on either side.

gzip is the universal floor — every client supports it. br is Brotli, which typically beats gzip by 15 to 20 percent on text and is supported by all current browsers over HTTPS. zstd is the newer arrival, faster to compress at similar ratios. Send whichever the client asked for; do not compress things that are already compressed, because a JPEG or a video re-gzipped just gets slightly bigger and costs you CPU.

One distinction to file away now, because it matters in the next section. Content-Encoding describes the representation itself — the body genuinely is gzip data, end to end, and it stays that way through every proxy. Transfer-Encoding describes how this one hop framed the message, and the next hop may frame it completely differently. They look similar and they are doing different jobs.

If the server can satisfy none of your preferences it may return 406 Not Acceptable — but in practice most servers just send their default representation instead, and the specification explicitly permits that. Failing a request because someone asked for XML is usually worse for the user than sending them JSON they did not ask for.

When the Body Is Too Big

Everything so far assumed a body small enough to build in memory, count, and send. Files break that assumption in both directions — going up and coming down.

Going up: multipart/form-data. The obvious approach to uploading a file over JSON is to base64-encode it, and it works, and it costs you roughly 33% more bytes for nothing plus an encode and decode on both ends. HTTP has a better answer that predates the problem: split one body into several parts, each with its own headers and its own raw bytes.

The trick is the boundary — a delimiter string, declared in the Content-Type, that separates the parts. It has to be a sequence that does not occur anywhere inside the data, which is why real ones look like keyboard mashing. That is deliberate: the client picks something long and random precisely so that it cannot collide with the file's contents.

Read the structure and it is simple. Each part opens with two hyphens plus the boundary, then its own headers, then a blank line, then its content. Content-Disposition names the form field; a file part adds a filename and its own Content-Type. The final boundary carries two extra hyphens on the end — that is the terminator, and it is how the server knows there is nothing more coming.

Notice that the PDF bytes go across raw. No encoding, no escaping, no inflation. That is the entire point.

Coming down: two different problems. The first is that sometimes the server does not know how big the response is when it starts sending — it is generating a report, streaming rows out of a database, or proxying something. It cannot send Content-Length, and without a length the client has no idea where the body ends.

The HTTP/1.1 answer is Transfer-Encoding: chunked. The body arrives as a series of chunks, each prefixed with its own size in hexadecimal, terminated by a zero-length chunk:

http
HTTP/1.1 200 OK
Content-Type: application/json
Transfer-Encoding: chunked

1a
{"row":1,"name":"widget-a"}
1b
{"row":2,"name":"widget-bb"}
19
{"row":3,"name":"widget"}
0

# Each hex number is the byte length of the chunk that follows.
# The 0-length chunk means: that was all of it.

The client can start processing chunk one while the server is still computing chunk three. Nobody has to know the total in advance. (This is an HTTP/1.1 mechanism specifically — HTTP/2 and HTTP/3 do their own framing with binary DATA frames and have no Transfer-Encoding at all.)

The second problem is the genuinely large file: a 4 GB video, on a connection that will drop before it finishes. The answer there is not chunking, it is range requests — asking for a byte interval instead of the whole thing.

http
GET /media/talk.mp4 HTTP/1.1
Host: cdn.example.com
Range: bytes=2097152-4194303

HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Range: bytes 2097152-4194303/1073741824
Content-Length: 2097152
Content-Type: video/mp4

<...2 MB of video...>

Accept-Ranges: bytes is the server advertising that it supports this at all. 206 Partial Content is the success code for a satisfied range. Content-Rangespells out which slice you got and how large the whole file is. This one header pair is the mechanism behind resumable downloads, parallel download accelerators, and the ability to drag a video's scrubber to the middle and have it start playing there instead of downloading two gigabytes first.

Here I have to correct something in my own notes, because I had a third mechanism filed alongside these two and it does not belong there. I had written down Content-Type: text/event-stream as a way of streaming large downloads. It is not. text/event-stream is Server-Sent Events — a one-way push channel where the server holds a connection open and sends small text-framed messages to the browser as things happen. Live scores, notification badges, progress updates, a chat feed. It is a different tool for a different job:

http
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-store
Connection: keep-alive

event: progress
data: {"job":"export-8812","percent":40}

event: progress
data: {"job":"export-8812","percent":75}

event: done
data: {"job":"export-8812","url":"/exports/8812.csv"}

Those are events, not a file. SSE is for telling the client that something happened. Chunked transfer is for framing a body of unknown length. Range requests are for moving a big file reliably. Three mechanisms that all involve a connection staying open for a while, which is presumably why they landed on the same page of my notes — and they are not substitutes for each other.

Wrapping It All in TLS

Go back to the very first example in this post — a request typed into a raw socket, in plain text, that anyone on the path could read. Every router, every Wi-Fi access point, every network in between sees the URL, the headers, the cookies, the body. On the open internet that is not a protocol, it is a postcard.

TLS — Transport Layer Security — is the fix, and it is worth being precise about the name because the industry is not. SSL (Secure Sockets Layer) was the original, from Netscape in the mid-90s. It was renamed TLS in 1999 and every SSL version is now formally deprecated and unsafe. So are TLS 1.0 and 1.1, retired in 2021. What actually runs today is TLS 1.2 and TLS 1.3. We all still say "SSL certificate" out of pure habit; the certificate is an X.509 certificate and the protocol is TLS.

TLS gives you three properties, and it is worth separating them because people collapse them into "it is encrypted":

Confidentiality. The bytes are encrypted, so an observer sees ciphertext. Integrity. Tampering is detectable — someone in the middle cannot flip a bit in your response without the other end noticing and rejecting it. Authentication. The certificate, signed by an authority your device already trusts, proves the server you are talking to really controls that domain. That third one is the property that stops an attacker from simply presenting their own encrypted connection instead.

The handshake happens before any HTTP moves:

text
Client                                          Server
  |-- ClientHello ------------------------------->|   TLS versions I support,
  |    (+ SNI: "api.example.com")                 |   cipher suites, a key share,
  |                                               |   and which host I want
  |<------------------------- ServerHello --------|   chosen version + cipher,
  |<------------------------- Certificate --------|   its key share, and the
  |<------------------------- Finished -----------|   certificate chain
  |                                               |
  |-- (verify chain, derive keys) --------------->|
  |-- Finished ---------------------------------->|
  |                                               |
  |=========== everything past here is encrypted ==========|
  |-- GET /api/orders HTTP/1.1 ------------------>|

TLS 1.3 got this down to one round trip, having cut the legacy cipher suites and the extra negotiation step that TLS 1.2 needed. And note what happens on the last line: the HTTP request is byte-for-byte the same request from the start of this post. TLS did not change HTTP by one character. It built an encrypted pipe and HTTP walked through it.

HTTPS is exactly that and nothing more — HTTP, unmodified, spoken inside a TLS tunnel, on port 443 instead of 80. There is no separate secure protocol. The layering is the entire design.

Thirty years of securing the web, and HTTP itself never learned a single thing about cryptography. We did not make the text secret. We built a tunnel and sent the same text through it.

Be clear-eyed about what TLS does not hide, because "the padlock" gets read as more than it is. An observer still sees the IP address you connected to. They see the hostname in plaintext in ClientHello via SNI (Server Name Indication, the extension that lets one IP serve many TLS sites — the same problem Host solved for plain HTTP, one layer down), unless Encrypted Client Hello is in play, which is still rolling out. They see the size and timing of your traffic, which leaks more than people expect. And unless you are using DNS-over-HTTPS or DNS-over-TLS, they watched you look the domain up before you connected at all. TLS hides the contents of the conversation. It does not hide that the conversation is happening.

The one server-side habit worth taking from this: send Strict-Transport-Security. Redirecting HTTP to HTTPS still leaves that first plaintext request exposed to being intercepted before the redirect ever arrives. HSTS makes the browser remember to never try plain HTTP for your domain again — the first visit is the only one at risk.

Key Takeaway

The protocol at the centre of all of this is small enough to type by hand. A verb, a path, a version, some Key: Value lines, a blank line, a body. That is what a browser sends, what your API receives, and what still sits at the bottom of HTTP/3 running over QUIC inside a TLS 1.3 tunnel.

HTTP is just text. Everything else is cleverness. Persistent connections, multiplexing, headers, CORS, caching, negotiation, TLS — none of them changed the message. They are thirty years of layers built around a shape nobody could improve on.

And that is genuinely useful, not just tidy, because it tells you where to look when something is wrong. Nearly every HTTP problem you will debug lives in one of the layers, not in the message: a header you did not send, a cache you did not invalidate, a preflight you did not answer, a redirect that ate your body, a retry that was never safe to make. The text almost always arrived fine.

Read the raw request before you guess, send Vary whenever your response depends on a request header, use 307 and 308 when the method has to survive, make an operation idempotent before you make it retryable, and remember that CORS is protecting your user and not your server. The protocol is text. Almost everything that goes wrong sits in the cleverness on top of it.

Resources