All articles
HTTPAPI DesignNetworking12 min read

HTTP Status Codes and Headers: The Practical Guide

Which status code to return and why, the headers that control caching, auth, CORS, compression and rate limiting, and the error-response shape that keeps clients sane.

Why the status code matters

The status code is the part of your response that machines act on without parsing your body. Load balancers count 5xx to eject an unhealthy instance. Client SDKs retry 502 and 503 with backoff and refuse to retry 400. CDNs cache 200 and 404 and never cache 500. Monitoring pages someone at 3am based on a 5xx rate.

The 200-with-error anti-pattern
HTTP 200 {"success": false, "error": "not found"} makes every one of those systems blind. Your error rate looks like zero, retries never happen, and every client has to write bespoke parsing. Use the status line.
1xxInformationalrare2xxSuccessit worked3xxRedirectionlook elsewhere4xxClient errordo not retry as-is5xxServer errorretry with backoffThe first digit is the only thing generic middleware reads. Get the class right before you argue about the exact number.
Five classes. The first digit tells generic infrastructure everything it needs.

2xx: success is not just 200

CodeUse whenNotes
200 OKRead or update succeeded and there is a bodyDefault success
201 CreatedA new resource existsAlways include Location
202 AcceptedWork was queued, not doneReturn a job URL to poll
204 No ContentSucceeded, nothing to sayDELETE, PUT with no echo. No body at all
206 Partial ContentRange request servedVideo seeking, resumable downloads

202 is underused. Anything that takes more than a second or two — transcoding, report generation, bulk import — should return 202 with a job resource rather than holding a socket open until a gateway kills it at 30 seconds.

3xx: redirects and 304

CodeMeaningMethod preserved?
301 Moved PermanentlyNew canonical URL, cached forever by browsersNo — may become GET
302 FoundTemporary, historically ambiguousNo — may become GET
303 See OtherPOST succeeded, now GET thisForced to GET
304 Not ModifiedYour cached copy is still freshn/a — no body
307 Temporary RedirectTemporary, method and body preservedYes
308 Permanent RedirectPermanent, method and body preservedYes

For APIs prefer 307/308 — 301 and 302 are allowed to rewrite a POST into a GET, which silently drops your body. 303 is the correct answer to the “refresh resubmits the form” problem (Post/Redirect/Get).

4xx: the client's fault

CodeUse when
400 Bad RequestMalformed syntax — unparseable JSON, missing required param
401 UnauthorizedNo or invalid credentials. Include WWW-Authenticate
403 ForbiddenAuthenticated but not permitted. Retrying will not help
404 Not FoundNo such resource — also use to hide existence from unauthorised users
405 Method Not AllowedWrong verb on a real path. Must include Allow header
409 ConflictState conflict — duplicate unique key, concurrent edit
410 GoneDeliberately removed and not coming back
412 Precondition FailedIf-Match did not hold — optimistic concurrency rejection
413 Payload Too LargeBody over the limit
415 Unsupported Media TypeContent-Type your endpoint cannot parse
422 Unprocessable ContentSyntactically valid, semantically wrong — validation errors
429 Too Many RequestsRate limited. Must include Retry-After

400 vs 422 is the one people argue about. Useful rule: 400 if you could not parse it, 422 if you parsed it fine but the values are unacceptable. Field-level validation errors are 422.

5xx: your fault

  • 500 — unhandled exception. Never leak a stack trace; log with a request id and return that id.
  • 501 — not implemented. Rare and honest.
  • 502 — a bad response from an upstream you proxy to.
  • 503 — temporarily unavailable: shedding load, in maintenance, circuit open. Include Retry-After.
  • 504 — an upstream timed out. Distinguish from 502; they point at different problems.
5xx is a retry signal
Clients are entitled to retry 502/503/504 with exponential backoff and jitter. That means every write endpoint that can return them needs idempotency, or your outage becomes a wave of duplicates the moment it recovers.

Error response shape

Pick one shape and use it everywhere. RFC 9457 (application/problem+json) is a good default because it is already a standard and tooling understands it.

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/validation",
  "title": "Validation failed",
  "status": 422,
  "detail": "quantity must be at least 1",
  "instance": "/v1/orders",
  "requestId": "3f9a-77cd",
  "errors": [
    { "field": "qty", "code": "min", "message": "must be >= 1" }
  ]
}
Machine-readable code, human-readable detail, per-field errors, correlation id.
  • A stable machine code lets clients branch without string-matching messages.
  • The requestId must also appear in your logs — that is the whole point.
  • Never put raw SQL, file paths or internal hostnames in an error body.

Headers by category

CategoryHeadersPurpose
ContentContent-Type, Content-Length, Content-Encoding, Content-DispositionHow to parse, decompress and save the body
NegotiationAccept, Accept-Encoding, Accept-Language, VaryClient capabilities and the cache key partition
CachingCache-Control, ETag, Last-Modified, Expires, AgeFreshness and revalidation
ConditionalIf-None-Match, If-Match, If-Modified-Since304s and optimistic concurrency
AuthAuthorization, WWW-Authenticate, Set-Cookie, CookieIdentity and session
CORSAccess-Control-Allow-*, OriginCross-origin browser permission
TracingX-Request-Id, traceparentDistributed tracing correlation
ProxyX-Forwarded-For, X-Forwarded-Proto, ForwardedOriginal client details behind a proxy
Vary is not optional
If a response differs by Accept-Encoding, Accept-Language or Authorization, say so with Vary. A shared cache that ignores this will happily serve one user's authenticated response to the next visitor.

Rate limiting headers

HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 37
Retry-After: 37
Tell the client the budget so it can behave, instead of hammering you.

Retry-After accepts seconds or an HTTP date, and is also valid on 503 during maintenance. Publishing the remaining budget on every response — not just on rejections — is what lets a well-written client throttle itself before it gets blocked.

Security headers

HeaderValue to start fromProtects against
Strict-Transport-Securitymax-age=31536000; includeSubDomainsProtocol downgrade, SSL stripping
Content-Security-Policydefault-src 'self'XSS and injected third-party scripts
X-Content-Type-OptionsnosniffMIME sniffing turning uploads into scripts
Referrer-Policystrict-origin-when-cross-originLeaking URLs and tokens to third parties
X-Frame-Options / frame-ancestorsDENYClickjacking
Cache-Controlno-store on authenticated responsesPersonal data cached by proxies

Interview framing

“Status codes are the contract: 201 with Location on create, 202 with a job URL for async work, 422 with field-level problem+json for validation, 409 for state conflicts, 412 when an If-Match ETag is stale, and 429 with Retry-After plus RateLimit headers. 5xx is reserved for our own failures so it drives alerting and client backoff honestly. Every error carries a request id that also lands in the logs, and authenticated responses are always no-store with an explicit Vary.”

Keep reading

Suggested next articles based on this one.

Design it, don't just read it.

Practise LLD and system design problems with structured rubrics and AI feedback.

Start practising free