backend / protocols / http / 03_http_semantics_and_caching.md

HTTP Semantics and Caching

7 interview angles 7 min read source

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 negotiationAccept / 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 requestsIf-None-Match / ETag, If-Modified-Since / Last-Modified (caching, below).
  • AuthAuthorization: Bearer ..., WWW-Authenticate on a 401.
  • ConnectionConnection: keep-alive, Keep-Alive (HTTP/1.1 connection reuse).
  • ForwardingX-Forwarded-For / Forwarded (client IP through proxies), X-Forwarded-Proto. Only trust these from proxies you control.
  • RangeRange / Content-Range / 206 Partial Content — resumable downloads, video seeking.
  • CORSAccess-Control-Allow-Origin and friends (see 25_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 changeno-cache (revalidate with ETag every time) or a short max-age — fresh data, cheap 304 revalidation.
  • Per-user dataprivate, no-cache or private, max-age=... — never let a CDN/proxy serve one user’s data to another. (A Cache-Control mistake here is a real data-leak bug.)
  • Truly sensitive (auth tokens, financial detail) — no-store.
  • Tolerable stalenessstale-while-revalidate serves the slightly-stale copy instantly while refreshing behind the scenes.

Common gotchas

  • no-cache ≠ “don’t cache” — it means “revalidate before serving.” no-store is “don’t store.”
  • Caching per-user data in a shared cache — missing private lets 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-age too 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-For from 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 with If-None-Match: <etag>; if unchanged the server returns 304 Not Modified with no body and the client reuses its copy; if changed, 200 with the new body and ETag. Makes revalidation cheap even when you revalidate every time.
  • “What does Cache-Control: no-cache mean?”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” is no-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) plus no-cache or a short max-age. Omitting private on per-user data is a real data-leak bug. Truly sensitive responses get no-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.