HTTP Semantics and Caching
The parts of HTTP that don’t change between versions: methods, status codes, headers, and — the part most often done wrong — caching. A senior backend engineer is expected to be fluent here.
Methods — and their two key properties
| Method | Purpose | Safe? | Idempotent? |
|---|---|---|---|
| GET | read a resource | yes | yes |
| HEAD | GET without the body (metadata only) | yes | yes |
| OPTIONS | what’s allowed on this resource (CORS preflight) | yes | yes |
| POST | create / process; non-specific action | no | no |
| PUT | replace a resource at a known URI | no | yes |
| PATCH | partial update | no | no (usually) |
| DELETE | remove a resource | no | yes |
Two properties that drive real design decisions:
- Safe = no side effects (read-only). Safe methods are freely cacheable and retriable.
- Idempotent = doing it N times has the same effect as doing it once. PUT, DELETE, GET are idempotent; POST and PATCH are not.
Why idempotency matters: it decides what’s safe to retry. A network blip on a GET or PUT → retry freely. A blip on a POST → you might double-create. This is the whole reason idempotency keys exist for POSTs (see the payments worked design and 07_rest_apis/). “Which methods are idempotent and why does it matter” is a guaranteed question.
Status codes — the classes and the ones that matter
| Class | Meaning |
|---|---|
| 1xx | informational (rare; 101 Switching Protocols for WebSocket upgrade) |
| 2xx | success |
| 3xx | redirection |
| 4xx | client error — the client’s fault, don’t retry blindly |
| 5xx | server error — the server’s fault, retrying may help |
The ones a backend engineer must use correctly:
- 200 OK vs 201 Created (resource created — include a
Location) vs 202 Accepted (accepted, processing async) vs 204 No Content (success, nothing to return). - 301 (permanent redirect — cacheable forever) vs 302/307 (temporary). 308/307 preserve the method; 301/302 historically let clients switch POST→GET.
- 304 Not Modified — the caching workhorse (below).
- 400 (malformed request) vs 401 (not authenticated) vs 403 (authenticated but not authorized) vs 404 (not found) vs 409 (conflict — e.g. idempotency-key reuse, optimistic-lock failure) vs 422 (well-formed but semantically invalid) vs 429 (rate limited — include
Retry-After). - 500 (generic server error) vs 502 (bad gateway — upstream gave garbage) vs 503 (unavailable — overloaded/maintenance, often with
Retry-After) vs 504 (gateway timeout — upstream too slow).
The 4xx-vs-5xx distinction drives retry logic: retry 5xx and connect errors with backoff; do not retry 4xx (except 429 and 408 — those are “try again later,” not “your request is wrong”). See the resilience and aiohttp files.
Key headers
- Content negotiation —
Accept/Content-Type(media type),Accept-Encoding/Content-Encoding(gzip/br),Accept-Language. The client says what it wants; the server says what it sent. - Conditional requests —
If-None-Match/ETag,If-Modified-Since/Last-Modified(caching, below). - Auth —
Authorization: Bearer ...,WWW-Authenticateon a 401. - Connection —
Connection: keep-alive,Keep-Alive(HTTP/1.1 connection reuse). - Forwarding —
X-Forwarded-For/Forwarded(client IP through proxies),X-Forwarded-Proto. Only trust these from proxies you control. - Range —
Range/Content-Range/206 Partial Content— resumable downloads, video seeking. - CORS —
Access-Control-Allow-Originand friends (see25_security/).
HTTP caching — the part most often done wrong
Caching is what makes the web fast — and getting Cache-Control wrong either serves stale data or caches nothing.
Cache-Control — the directives
| Directive | Meaning |
|---|---|
max-age=N |
fresh for N seconds |
s-maxage=N |
max-age but for shared caches (CDN) — overrides max-age there |
no-cache |
may cache, but must revalidate before using (not “don’t cache”!) |
no-store |
genuinely don’t store this at all (sensitive data) |
private |
only the browser may cache it, not shared caches/CDN (per-user data) |
public |
any cache may store it |
must-revalidate |
once stale, must revalidate — don’t serve stale on error |
immutable |
won’t change for its lifetime — don’t even revalidate (hashed assets) |
stale-while-revalidate=N |
serve stale up to N seconds while refreshing in the background |
The classic mistake: thinking no-cache means “don’t cache.” It doesn’t — it means “cache it, but revalidate every time before serving.” The directive for “never store this” is no-store (use it for sensitive responses).
Conditional requests — ETag and 304
How a cache revalidates cheaply instead of re-downloading:
1. Server sends a response with ETag: "abc123" (a content fingerprint)
2. Client caches it. Later, client re-requests with If-None-Match: "abc123"
3a. Content unchanged → server replies 304 Not Modified with NO body
3b. Content changed → server replies 200 with the new body + new ETag
A 304 is tiny (headers only) — the client reuses its cached copy. So even with no-cache (revalidate every time), revalidation is cheap when content hasn’t changed. Last-Modified / If-Modified-Since is the timestamp-based equivalent (ETag is more precise — content-based, not clock-based).
Where caching happens — the layers
browser cache → CDN / edge cache → reverse proxy (nginx) → your app → app cache (Redis)
Cache-Control governs the HTTP caches (browser, CDN, reverse proxy). The application cache (Redis) is a separate concern with its own invalidation problem — see 09_caching/redis/. private keeps a response out of the shared caches (CDN/proxy) but lets the browser keep it; s-maxage tunes the CDN specifically.
Practical caching patterns
- Hashed static assets (
app.a1b2c3.js) —Cache-Control: public, max-age=31536000, immutable. The content hash is in the filename, so the URL changes when the content changes; cache forever, never revalidate. - HTML / API responses that change —
no-cache(revalidate with ETag every time) or a shortmax-age— fresh data, cheap 304 revalidation. - Per-user data —
private, no-cacheorprivate, max-age=...— never let a CDN/proxy serve one user’s data to another. (ACache-Controlmistake here is a real data-leak bug.) - Truly sensitive (auth tokens, financial detail) —
no-store. - Tolerable staleness —
stale-while-revalidateserves the slightly-stale copy instantly while refreshing behind the scenes.
Common gotchas
no-cache≠ “don’t cache” — it means “revalidate before serving.”no-storeis “don’t store.”- Caching per-user data in a shared cache — missing
privatelets a CDN serve user A’s response to user B. A data-leak bug. - No
ETag/Last-Modified— every revalidation re-downloads the full body instead of getting a tiny 304. max-agetoo long on changing content — users stuck with stale data with no way to bust it. Hash the URL, or use a short max-age + revalidation.- Retrying a POST on a network error — POST isn’t idempotent; you may double-create. Idempotency key, or only retry idempotent methods.
- Wrong status code — 200 with an error body (clients can’t branch on it), 404 for “not authorized” (leaks/confuses), 500 for a client’s bad input (should be 4xx). The status code is the contract.
- Trusting
X-Forwarded-Forfrom anywhere — it’s client-spoofable unless it came from a proxy you control.
Interview angle
- “Which HTTP methods are idempotent, and why does it matter?” — GET, HEAD, PUT, DELETE are idempotent; POST and PATCH are not. It matters because idempotency decides what’s safe to retry: a network blip on a GET or PUT can be retried freely, but retrying a POST risks a double-create — which is exactly why POSTs need idempotency keys.
- “Safe vs idempotent?” — safe = no side effects (read-only: GET, HEAD, OPTIONS) — freely cacheable. Idempotent = N calls have the same effect as one (adds PUT, DELETE). A method can be idempotent without being safe (DELETE).
- “Walk through HTTP caching with ETags.” — server sends an
ETag(content fingerprint); client caches and re-requests withIf-None-Match: <etag>; if unchanged the server returns304 Not Modifiedwith no body and the client reuses its copy; if changed,200with the new body and ETag. Makes revalidation cheap even when you revalidate every time. - “What does
Cache-Control: no-cachemean?” — not “don’t cache” — it means “you may cache it, but you must revalidate with the origin before serving it.” The directive for “never store this” isno-store. Confusing the two is the classic caching mistake. - “How do you cache a per-user API response?” —
private(so shared caches like a CDN can’t store it and serve it to another user) plusno-cacheor a shortmax-age. Omittingprivateon per-user data is a real data-leak bug. Truly sensitive responses getno-store. - “4xx vs 5xx — and how does it affect retries?” — 4xx is the client’s fault (malformed, unauthorized, not found) — don’t blindly retry, fix the request. 5xx is the server’s fault (or upstream) — retrying with backoff may succeed. Exceptions: 429 and 408 are “retry later” despite being 4xx.
- “When would you return 202 vs 201 vs 200?” — 201 Created: a resource was created synchronously (include a
Location). 202 Accepted: the request was accepted but is being processed asynchronously (a job was queued). 200 OK: success with a representation to return now.