HTTPhttprequestresponsemethodstatus code

HTTP: Requests, Responses, Headers and Status Codes

HTTP turns a byte stream into a request — method, path, headers, optional body — and a response — status code, headers, optional body; the method tells intermediaries whether a request is safe to retry and the status code tells the client what to do next.

Conceptual
▶ InteractiveInterview question
Progress

The problem

TCP delivers bytes with no boundaries and no meaning. A client needs to say "give me resource X" and a server needs to answer "here it is", "it moved", "you are not allowed" or "I broke" in a way that browsers, proxies, caches and load balancers written by different people can all understand — and that lets any of them decide whether a failed request can be safely retried.

Progressive depth

The same mechanism at different altitudes — start where you are.

Ask, answer, and a number saying how it went

A request says what to do to which resource; a response says how it went with a three-digit code and returns the data. Headers on both sides carry metadata: format, length, caching, authentication.

One request, one response

An HTTP/1.1 request is a request line — method, target, version — followed by header lines, a blank line, and an optional body whose length is announced by Content-Length or delimited by chunked encoding. The response is a status line — version, code, reason phrase — followed by headers, a blank line and a body. Header names are case-insensitive; the blank line (\r\n\r\n) is the only delimiter between headers and body, which is why a stray newline in a header value is a parsing bug and a security hole.

Host is mandatory in HTTP/1.1 because one IP address serves many sites: the TCP connection reached 93.184.216.34, and only the Host header says which of the hundred sites on that address the client meant. It is the HTTP-layer analogue of SNI in TLS, and a mismatch between the two is a common proxy misconfiguration.

A minimal request and response, exactly as the bytes appear on the wire
GET /users/42 HTTP/1.1
Host: example.com
Accept: application/json
Accept-Encoding: gzip, br
User-Agent: curl/8.5.0
Authorization: Bearer eyJhbGciOi...

HTTP/1.1 200 OK
Date: Tue, 25 Aug 2026 10:04:12 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 58
Cache-Control: private, max-age=0
ETag: "5f3a-1a2b"

{"id":42,"name":"Ada","email":"ada@example.com","plan":"pro"}

Methods: safety and idempotency

The method is a promise about side effects, and everything between client and server relies on it. A safe method (GET, HEAD, OPTIONS) must not change state, so caches may serve it and prefetchers may issue it speculatively. An idempotent method (GET, HEAD, PUT, DELETE, OPTIONS) produces the same server state whether it is executed once or five times, so a client, proxy or load balancer that lost the response may retry it. POST is neither: retrying "create an order" creates two orders. PATCH is not guaranteed idempotent (a "increment by one" patch is not; a "set to seven" patch is).

This is not academic. Browsers retry idempotent requests on a dropped Keep-Alive and Connection Reuse connection; curl --retry, gRPC, AWS SDKs and service meshes retry based on the method; a POST that times out at a load balancer after the server processed it is the origin of most duplicate-charge bugs. The fix for non-idempotent operations is an idempotency key (Idempotency-Key: 7a8f…) the server deduplicates on, which is the application re-creating the guarantee HTTP could not give it.

  • GET — read; safe, idempotent, cacheable. Body is allowed by the grammar but ignored by most servers and stripped by many proxies.
  • POST — create or "do something"; not idempotent; retry only with an idempotency key.
  • PUT — replace the whole resource at this URL; idempotent.
  • PATCH — partial update; idempotent only if the patch document is (set x=7 yes, increment x no).
  • DELETE — idempotent: the second call finds nothing and may return 404 or 204, but state is the same.
  • HEADGET without the body; OPTIONS — capabilities, used by CORS preflight.

Status codes engineers actually meet

The first digit is the class: 1xx informational, 2xx success, 3xx redirection, 4xx the client did something the server refuses, 5xx the server failed. A client that does not know a specific code must treat it as the x00 of its class. The specific codes below account for nearly all real traffic and nearly all real arguments about API design.

The three that come from proxies deserve their own lesson: 502 means the proxy got an invalid or no response from upstream (upstream down, wrong port, TLS mismatch), 503 means the upstream (or the proxy) declared itself unavailable (no healthy backends, overload, maintenance), 504 means the upstream did not answer within the proxy’s timeout. They tell you *which hop gave up and why* — see Load Balancers: L4 vs L7 and HTTP Debugging: 502, 503 and 504 Are Different Failures.

The codes that matter, by class
CodeMeaningWhen you see it / what to do
200 OKSuccess with bodyThe normal case. Also what many APIs wrongly return for errors ("200 with {error: …}") — do not.
201 CreatedResource createdResponse to POST/PUT that made something; Location header points at it.
204 No ContentSuccess, no bodyDELETE, or PUT that has nothing to say. Clients must not try to parse a body.
301 / 308Moved permanentlyCached by browsers and search engines. 301 may turn POST into GET; 308 preserves the method.
302 / 307Moved temporarilyNot cached. 302 historically rewrites POST to GET; 307 preserves the method. Use 307/308 for APIs.
304 Not ModifiedYour cached copy is still validAnswer to a conditional GET (If-None-Match / If-Modified-Since); no body, saves the transfer.
400 Bad RequestMalformed or invalid requestValidation failed, bad JSON, header too large. Do not retry unchanged.
401 UnauthorizedNot authenticatedMisnamed: it means "who are you?". Send credentials (WWW-Authenticate says how).
403 ForbiddenAuthenticated but not allowedCredentials are fine; permission is not. Retrying with the same identity will not help.
404 Not FoundNo such resourceAlso used to hide existence (instead of 403). Idempotent DELETE may return it on the second call.
409 ConflictState conflictOptimistic-concurrency failure (If-Match did not match), unique-key violation, "already exists".
429 Too Many RequestsRate limitedHonour Retry-After; back off with jitter, do not hammer.
500 Internal Server ErrorUnhandled server failureA bug or an exception; look at server logs, not the network.
502 Bad GatewayProxy got an invalid/no response from upstreamUpstream crashed, wrong port, upstream reset the connection, protocol mismatch.
503 Service UnavailableUpstream/proxy unavailableNo healthy backends, overload shedding, maintenance; Retry-After may be set.
504 Gateway TimeoutUpstream too slow for the proxyProxy timeout shorter than the upstream’s work; the upstream may still finish the work.

Headers: negotiation, caching, the body

Headers carry everything the message line does not. Content negotiation lets one URL serve several representations: Accept: application/json and Accept-Encoding: gzip, br say what the client can take; the server answers with Content-Type and Content-Encoding, and Vary: Accept-Encoding tells caches that the response depends on that request header. Compression is negotiated here, which is why a missing Accept-Encoding from a naive client makes every response several times larger.

Caching headers decide whether the next request happens at all. Cache-Control: max-age=300 lets a browser or CDN answer from cache for five minutes without asking; no-store forbids storing; private keeps it out of shared caches. After expiry, a conditional request with If-None-Match: "5f3a-1a2b" (the ETag from before) or If-Modified-Since lets the server answer 304 with no body. The same ETag in If-Match turns a PUT into an optimistic-concurrency update that fails with 412 or 409 if someone else changed the resource first.

The body is framed by Content-Length (exact byte count) or Transfer-Encoding: chunked (length-prefixed pieces, terminated by a zero chunk) — see HTTP/1.1: Persistent Connections and Their Limits for why both exist and why having both in one message is a smuggling attack. Content-Type is the only thing that tells the receiver how to parse it; application/json, application/x-www-form-urlencoded, multipart/form-data and text/html all cross the wire as bytes.

The same request from application code — every field above is set explicitly or by default
1const res = await fetch('https://example.com/users/42', {
2 method: 'GET', // safe + idempotent → the runtime may retry on a dead keep-alive socket
3 headers: {
4 Accept: 'application/json', // content negotiation
5 Authorization: `Bearer ${token}`,
6 'If-None-Match': cachedEtag ?? '', // conditional GET → 304 if unchanged
7 },
8})
9if (res.status === 304) return cached // no body on 304; do not call res.json()
10if (res.status === 429) {
11 const wait = Number(res.headers.get('Retry-After') ?? 1)
12 // back off; do not retry immediately
13}
14if (!res.ok) throw new Error(`HTTP ${res.status}`) // ok === 200..299
15const user = await res.json() // trusts Content-Type: application/json

Key points

  • Request = method + target + headers + optional body; response = status + headers + optional body; \r\n\r\n separates headers from body.
  • Host is mandatory because one address serves many sites; it is the HTTP-layer counterpart of SNI.
  • Safe methods can be cached and prefetched; idempotent methods can be retried by anyone on the path; POST is neither, so use idempotency keys.
  • 2xx success, 3xx go elsewhere, 4xx you did something wrong, 5xx the server or a proxy failed; 502/503/504 identify which hop gave up.
  • 307/308 preserve the method on redirect; 301/302 historically do not, and 301/308 are cached.
  • Content negotiation (Accept*/Content-*/Vary) picks a representation; Cache-Control, ETag and conditional requests decide whether the request happens at all.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why is HTTP text at all?

It was designed in 1991 to be written by hand over telnet and debugged by reading it, and that property made it easy to implement and to proxy. The cost — parsing ambiguity and repeated header bytes — is what HTTP/2 fixed with binary framing (see HTTP/2: Streams on One Connection).

Why do proxies care about the method?

Because a proxy that lost the response has to decide whether to retry, and it cannot know what the application does. The method is the one contract it can rely on: idempotent requests are safe to resend, POST is not.

Why distinguish 401 from 403?

They prompt different client behaviour. 401 says "authenticate and try again" and carries a WWW-Authenticate challenge; 403 says "I know who you are and the answer is no", so retrying with the same credentials is pointless.

Why do status codes have classes?

So a client written before a code existed still behaves sensibly: an unknown 4xx is treated as 400, an unknown 5xx as 500. Extensibility without a registry lookup.

Anatomy of an HTTP message

Anatomy of an HTTP message
Click any line of the raw request or response to see what it is for. Change the method and status code to see how the text changes.
Method
safeidempotent
Read a representation. Cacheable; must not change server state — a crawler may call it any number of times.
Request (client → server)
GET /users/42 HTTP/1.1Host: api.engineer-atlas.devAccept: application/jsonAuthorization: Bearer eyJhbGciOiJIUzI1NiJ9…
Response (server → client)
HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8Content-Length: 61Cache-Control: private, max-age=60{"id":42,"name":"Ada","role":"admin","createdAt":"2026-08-25"}
Click a line to see its purpose. Note that both messages are plain text with the same shape: start line, headers, blank line, optional body.
Status code
200 OK means
Success, body follows
Typical cause
a normal GET / PUT / PATCH
The same request in TypeScript
const res = await fetch('https://api.engineer-atlas.dev/users/42', {
  method: 'GET',
  headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) // 200 → ok
const user = await res.json()

How it fails

What the failure looks like from inside real software.

  • Duplicate orders: a POST retried by a client library or load balancer after a timeout; the server processed both. Add an idempotency key.
  • Errors returned as 200 OK with an error body: caches store them, monitoring sees a healthy service, and retry logic never triggers.
  • 302 after a POST turns the follow-up into a GET and loses the body; use 307/308.
  • Missing Vary: Accept-Encoding at a cache serves gzip bytes to a client that did not ask for them; the page renders as garbage.
  • Response has both Content-Length and Transfer-Encoding: chunked; front and back servers disagree about where the message ends — request smuggling.
  • Client calls res.json() on a 204 or 304 and throws on the empty body.
Don't delegate understanding
The manifesto →