HTTP from the Ground Up: Request, Response, Headers
Every request your browser makes, every API call your server handles, every webhook that fires - they’re all HTTP. Most engineers interact with it through abstractions: fetch, axios, an ORM’s query method. The abstractions are fine. But when something goes wrong - a 422 that should be a 400, a CORS error you can’t explain, a cache that won’t invalidate - the abstraction is exactly where the problem hides.
HTTP is not complicated. The format is text. The rules are documented. Understanding it takes less than an afternoon.
What HTTP actually is
HTTP is a protocol for transferring data over a network. It runs on top of TCP, which handles the reliable delivery of bytes. HTTP’s job is to define what those bytes mean.
A client sends a request. A server sends a response. That’s the entire model. HTTP is stateless - each request-response pair is independent. The server holds no memory of previous requests unless you build that memory explicitly (with sessions, tokens, or cookies).
HTTP/1.1 is text-based. Every request and response is a sequence of ASCII characters followed by binary body data. HTTP/2 and HTTP/3 encode the same concepts as binary frames for efficiency, but the logical structure is identical.
The request
An HTTP request has three parts: a request line, headers, and optionally a body.
POST /api/orders HTTP/1.1
Host: api.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGci...
Content-Length: 47
{"productId": "abc123", "quantity": 2}
The request line is always first: method, path, HTTP version. The path is everything after the host - including query parameters.
The method tells the server what the client wants to do:
GET- retrieve a resource; no body; should be idempotent and safePOST- submit data; typically creates something; not idempotentPUT- replace a resource at a given path; idempotentPATCH- partially update a resource; not necessarily idempotentDELETE- remove a resource; idempotentHEAD- same as GET but without the response body (useful for checking if a resource exists)OPTIONS- ask the server what methods are allowed (used by CORS preflight)
Idempotent means calling it multiple times has the same effect as calling it once. DELETE /users/5 called twice should return 404 on the second call, but the state of the world is the same as after the first call. This property matters for retry logic.
Headers are key-value pairs, one per line, separated from the body by a blank line. They carry metadata about the request: what format the body is in, who’s making the request, what the client accepts in response.
The body is present in POST, PUT, and PATCH requests. Its format is whatever the Content-Type header declares.
The response
A response has the same structure: a status line, headers, blank line, body.
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/orders/9f3a
Cache-Control: no-store
{"id": "9f3a", "status": "pending"}
The status line contains the HTTP version, a numeric status code, and a reason phrase. The reason phrase is ignored by code - it’s for humans reading raw responses.
Status codes are grouped by their first digit:
- 1xx - informational (rare in practice;
101 Switching Protocolsfor WebSocket upgrades) - 2xx - success:
200 OK,201 Created,204 No Content - 3xx - redirection:
301 Moved Permanently,302 Found,304 Not Modified - 4xx - client error:
400 Bad Request,401 Unauthorized,403 Forbidden,404 Not Found,409 Conflict,422 Unprocessable Entity,429 Too Many Requests - 5xx - server error:
500 Internal Server Error,502 Bad Gateway,503 Service Unavailable
The distinction between 401 and 403 trips up many engineers: 401 means the request lacks valid authentication credentials - the client should authenticate and retry. 403 means the server understood who you are, but you don’t have permission - retrying with the same credentials won’t help.
Headers worth knowing
Headers are where most of the useful HTTP semantics live. A selection of the ones you’ll deal with regularly:
Content-Type - declares the format of the body. For requests: application/json, application/x-www-form-urlencoded, multipart/form-data. For responses: the same, plus text/html, text/plain, image/webp. Without this header, the receiver has to guess.
Accept - tells the server what content types the client can handle. The server should respond with one of them (or 406 Not Acceptable if it can’t).
Authorization - carries credentials. The two common schemes: Basic (base64-encoded username:password, insecure without HTTPS) and Bearer (a token, typically a JWT).
Cache-Control - instructs caches on how to store the response. no-store prevents caching entirely. no-cache allows storing but requires revalidation before use. max-age=3600 says the response is fresh for one hour. This header is on both requests and responses.
ETag and Last-Modified - response headers used for conditional requests. On subsequent requests, the client sends If-None-Match or If-Modified-Since. If the resource hasn’t changed, the server returns 304 Not Modified with no body - saving bandwidth.
Location - used in 3xx redirects and 201 responses to point to the resource’s URL.
Set-Cookie and Cookie - how sessions are maintained. The server sets a cookie in the response; the browser sends it back with every subsequent request to that domain.
CORS headers (Access-Control-Allow-Origin, etc.) - tell browsers which cross-origin requests are permitted. These are server-controlled; you can’t override them from the client.
How connections work
HTTP/1.1 defaults to persistent connections (Connection: keep-alive). The TCP connection stays open between requests to avoid the overhead of establishing a new one for every resource.
HTTP/1.1 has head-of-line blocking: responses for a given connection are returned in order. If a large response is slow, it blocks smaller responses behind it on the same connection. Browsers work around this by opening multiple connections per domain (typically 6).
HTTP/2 multiplexes multiple requests over a single TCP connection using binary frames. Responses can arrive in any order. This mostly eliminates the need for domain sharding and makes the connection limit less of a bottleneck.
HTTP/3 replaces TCP with QUIC (a protocol built on UDP). It eliminates TCP’s own head-of-line blocking problem, which shows up with packet loss. The application-level semantics are the same as HTTP/2.
What this changes about debugging
When a request fails, the status code is the first signal. But the headers are often where the actual problem is:
- A
200that returns no data when you expected JSON is usually aContent-Typemismatch - the client didn’t set it, so the server parsed the body wrong. - A
401on a perfectly valid token is often a malformedAuthorizationheader - missingBearer, wrong case, trailing whitespace. - A cache that won’t invalidate is usually a
Cache-Controlheader from the CDN or server that you didn’t set intentionally. - A CORS error in the browser is the server not returning the right
Access-Control-*headers, or the client sending a request the server’s CORS policy doesn’t allow.
Reading the raw request and response - in browser devtools, curl -v, or a proxy like Proxyman or Wireshark - bypasses every abstraction and shows you what’s actually happening on the wire. It’s the fastest path to the real problem.