Chapter 4 — HTTP Status Codes, and Who Sent Them
A Status Code Is a Claim — First Find Out Who Is Making It
Part 1: Foundations. Prerequisites: Chapters 1–3.
Opening
Every HTTP response starts with a three-digit status code. Most engineers know the famous ones — 200, 404, 500 — and treat the rest as noise. That loses most of the evidence.
A status code is a precise claim about what happened to a request. And here is the part almost everyone misses: it is a claim made by whoever produced the response — which is often not your application. Between the browser and your code sit a CDN, a load balancer, a reverse proxy, maybe an API gateway. Any of them can answer the request themselves. A 502 from your load balancer, a 403 from a CDN's firewall and a 403 from your own permission check look identical in a quick glance and mean completely different things.
This chapter gives you two skills. First, what each status code actually claims. Second — and more important — how to work out which hop sent it, because that decides where you look next.
Part 1: The Chain a Request Travels Through
A request from a browser to your API usually passes through several systems. Each one can either forward the request onward or answer it directly.
The words for these roles:
- Client — whoever sends the request (the browser, a mobile app, another service).
- Origin — your actual server, the thing the CDN or proxy forwards to.
- Proxy / reverse proxy — a server that receives requests on behalf of others and forwards them. nginx and load balancers are reverse proxies.
- Gateway — a proxy acting as the entry point to backend services. In status-code language, any proxy forwarding to an upstream is "the gateway" — which is why
502and504are called Bad Gateway and Gateway Timeout. - Upstream — the next system toward the origin, from a given hop's point of view. To the load balancer, your app is upstream. To your app, the database is upstream.
- Downstream — the direction back toward the client.
So 502 Bad Gateway means: "I am a proxy, and the upstream I forwarded to gave me a bad response or none." It is generated by the proxy. Your app may never have seen the request, or may have crashed handling it.
Part 2: Who Sent This Response?
Before interpreting any error status, answer this question. Four kinds of evidence settle it.
1. Response headers. Each layer tends to stamp its own:
| Header | Who it points to |
|---|---|
server: nginx, server: cloudflare, server: Vercel | Which software produced the response |
cf-ray, cf-cache-status | Cloudflare handled it |
x-vercel-id, x-vercel-cache, x-vercel-error | Vercel handled it; x-vercel-error names a platform error |
x-amz-cf-id, x-cache: Error from cloudfront | AWS CloudFront |
via | Lists the proxies the response passed through |
x-request-id / your own correlation header | Your app produced or touched it — and gives you the ID to search your logs for |
2. The body. Your API returns errors in its own JSON shape, like { "error": { "code": "ORDER_NOT_FOUND" } }. A plain HTML page that says "502 Bad Gateway — nginx" or a branded CDN error page was not written by your code.
3. Timing. How long did the request take? A 504 that arrives after exactly 30.0 or 60.0 seconds is almost certainly a proxy's configured timeout, not something your code decided. An error that arrives in 3 milliseconds probably never reached a slow backend.
4. Your app's logs. Search for the request (by time, path, or request ID). If your app has no record of it, the response came from a layer in front of your app — and debugging your app's code is pointless until you know why the request never reached it.
Part 3: The Five Classes
The first digit is the class. It tells you whose fault it is likely to be and whether retrying makes sense.
| Class | Meaning | Whose problem | Retry unchanged? |
|---|---|---|---|
1xx | Informational — "keep going" | Nobody's | Not applicable |
2xx | Success | Nobody's | Not needed |
3xx | Redirection — "look elsewhere" | Nobody's, usually | The client follows it |
4xx | Client error — the request is the problem | The caller's request (from the server's point of view) | No — except 408 and 429 |
5xx | Server error — the server failed | A server somewhere in the chain | Maybe — only if the request is safe to repeat |
The 4xx/5xx split is the most useful part. A 4xx says "this exact request will keep failing until something about it changes." A 5xx says "this request may be fine; something on the server side broke."
Part 4: 1xx, 2xx and 3xx — the Ones That Still Cause Bugs
1xx — rarely seen, occasionally important
101 Switching Protocols— the server agreed to upgrade the connection, most often to a WebSocket. If a WebSocket fails to connect, check whether you got101; a proxy that does not support upgrades returns something else.103 Early Hints— the server sends hints (like "start loading this CSS") before the real response is ready.
2xx — success, with traps
| Code | Meaning | The trap |
|---|---|---|
200 OK | Success, with a body | A 200 can still carry an error in the body if an API is badly designed — always check the body, not only the status |
201 Created | A resource was created; its URL is usually in the Location header | |
202 Accepted | The request was queued, not completed | The work has not happened yet. "It returned 202 but nothing happened" is often a background job that failed later |
204 No Content | Success, with no body | Calling response.json() on a 204 throws Unexpected end of JSON input |
206 Partial Content | Only part of the resource, as requested with a Range header | Normal for video and large downloads |
3xx — redirects and caching
| Code | Meaning | Method kept on redirect? | Cached by browser? |
|---|---|---|---|
301 Moved Permanently | Use the new URL forever | Browsers may change POST to GET | Yes, aggressively |
308 Permanent Redirect | Same as 301, but strictly keeps the method and body | Yes | Yes |
302 Found | Temporarily elsewhere | Browsers may change POST to GET | No (by default) |
307 Temporary Redirect | Same as 302, but strictly keeps the method and body | Yes | No |
303 See Other | "Go GET this other URL" — the standard response after a form POST | Always becomes GET | No |
304 Not Modified | "Your cached copy is still valid" — no body sent | — | — |
Three redirect bugs worth knowing:
- A wrong
301sticks. Browsers cache permanent redirects, so even after you fix the server, users who saw the bad301keep being redirected until their cache clears. Use302/307while you are unsure. - A
POSTredirected with301/302can arrive as aGETwith no body. If a form submission "loses its data", check for a redirect — commonlyhttp→httpsor a missing/extra trailing slash — and use307/308or fix the URL. fetchfollows redirects silently. You get the final response, not the redirect. Checkresponse.redirectedandresponse.url. A response that "should be JSON" but is the login page HTML is usually a silent redirect to/login(Chapter 2).
304 and the browser cache. A conditional request asks "has this changed since my copy?" using If-None-Match (with an ETag) or If-Modified-Since. 304 means "no, use your copy." In DevTools, also watch for 200 (memory cache) or 200 (disk cache) — those were served from the browser's cache without any request at all. If a change "isn't showing up", check whether the response came from cache.
Part 5: 4xx — The Request Is the Problem
| Code | Name | What it actually claims | Usual causes |
|---|---|---|---|
400 | Bad Request | The request is malformed — the server cannot even process it | Invalid JSON syntax, a missing required parameter, a wrong type |
401 | Unauthorized | "I don't know who you are." Authentication missing or invalid, despite the name | No token, expired token, wrong token, cookie not sent (a cross-site cookie blocked by SameSite) |
403 | Forbidden | "I know who you are, and you can't do this." — or a firewall refused the request | Missing role or permission, wrong tenant, or a WAF/CDN blocking the request — check who sent it |
404 | Not Found | Nothing at this URL — or the server is hiding that it exists | Wrong path, wrong base URL, trailing slash, the resource was deleted, a route that only exists in another environment, or a permission check that returns 404 on purpose |
405 | Method Not Allowed | The URL exists but not with this method; the Allow header lists valid ones | POST to a GET-only route; a CORS preflight OPTIONS the server does not handle |
408 | Request Timeout | The client was too slow to send the request | Slow uploads, stalled connections. Safe to retry |
409 | Conflict | The request conflicts with the current state | A duplicate, a version mismatch, an action on something already changed |
410 | Gone | It existed and was removed permanently | Deleted resources, retired API versions |
412 | Precondition Failed | A condition like If-Match was false | Optimistic concurrency: the resource changed since you read it — see SD QA Q27 |
413 | Content Too Large | The body exceeds a size limit | Usually a proxy limit, not your code — nginx defaults to 1 MB (client_max_body_size); serverless platforms cap request bodies too |
414 | URI Too Long | The URL is too long | Huge query strings — move data to the body |
415 | Unsupported Media Type | The server does not accept this Content-Type | Sending form data to a JSON endpoint, or forgetting Content-Type: application/json |
422 | Unprocessable Content | The request is well-formed but invalid | Validation failures: email format, missing business fields. (Some APIs use 400 for this instead — conventions differ) |
429 | Too Many Requests | You hit a rate limit; Retry-After may say how long to wait | Retrying too aggressively, a loop, a bot, or a shared IP. Retry after waiting |
431 | Request Header Fields Too Large | Headers are too big | Almost always too many or too large cookies accumulated on the domain |
499 | Client Closed Request | Not standard — nginx's log code for "the client disconnected before I answered" | The user navigated away, or the client's timeout is shorter than the server's processing time |
401 versus 403 — the pair everyone mixes up
401 Unauthorized | 403 Forbidden | |
|---|---|---|
| Really means | Not authenticated | Not authorised |
| The server's view | "Who are you?" | "I know who you are. No." |
| Fix | Log in, refresh the token, send the credentials | Get the permission, use the right account or tenant |
| Debug first | Is the token or cookie actually sent? Is it expired? | Which permission check failed — and did your app or a firewall send it? |
The 404 that only happens on refresh
A single-page app works when you click around, but refreshing /dashboard/settings gives 404. The browser asked the server for /dashboard/settings as a real file, and a static host has no such file — the route only exists inside the JavaScript router. The host must be configured to serve index.html for unknown paths.
Part 6: 5xx — Something on the Server Side Failed
| Code | Name | Who usually sends it | What it claims |
|---|---|---|---|
500 | Internal Server Error | Your app (or the platform running it) | An unhandled error while processing the request. The answer is in your app's logs |
501 | Not Implemented | Server | The server does not support this method or feature at all |
502 | Bad Gateway | A proxy | The upstream gave an invalid response or none: it crashed, refused the connection, reset it, or closed a reused connection |
503 | Service Unavailable | A proxy or your app | Not able to handle it right now: no healthy upstream, overloaded, maintenance, or deliberate load shedding. May include Retry-After |
504 | Gateway Timeout | A proxy | The proxy gave up waiting for the upstream within its timeout |
502 versus 504 — fast failure or slow failure
502usually arrives quickly. The proxy tried the upstream and got a refused or broken connection. Look at whether the app is running and healthy: crashes, restarts, deploys,ECONNRESET, keep-alive mismatches (Chapter 3).504arrives after the proxy's timeout. The upstream accepted the request and did not answer in time. Look at why the app is slow: a slow query, a lock, a hung call to a third-party API, an exhausted connection pool.
The dangerous thing about 504
A 504 means the proxy stopped waiting. It does not mean your app stopped working. The app may still be processing — and may finish successfully after the client has already been told it failed.
POST /checkoutIdempotency keys are covered in depth in SD QA Q26.
Platform and CDN codes
CDNs and platforms add their own codes and labels. Learn the ones for whatever you deploy on.
- Vercel names platform errors in the
x-vercel-errorheader and the error page — for exampleFUNCTION_INVOCATION_TIMEOUT(504: your function exceeded its time limit),FUNCTION_INVOCATION_FAILED(500: your function crashed),FUNCTION_PAYLOAD_TOO_LARGE(413). - Cloudflare uses the non-standard
52xrange between Cloudflare and your origin:520(the origin returned something unexpected),521(the origin refused the connection — it is down),522(connecting to the origin timed out),524(connected, but the origin took too long to respond),525/526(TLS problems with the origin).
A 52x tells you immediately: Cloudflare is fine; the hop from Cloudflare to your origin failed.
Part 7: Things That Look Like Status Codes but Are Not
Some of the most confusing "errors" in the Network tab are not HTTP statuses at all.
| What DevTools shows | What it means |
|---|---|
(failed) with net::ERR_… | No HTTP response arrived. Read the net::ERR_ code (Chapter 3) |
CORS error | The browser received a response but refused to let your JavaScript read it, because it lacked the right Access-Control-Allow-* headers. The server may have returned 200 — or the real error may be hidden behind CORS |
A failed OPTIONS request before your real request | The CORS preflight failed. Preflights carry no cookies or auth headers, so a server that requires auth on OPTIONS returns 401/403 and the real request never happens |
(blocked:mixed-content) | An https page tried to load an http resource — the browser blocks it |
(canceled) | The browser cancelled the request: navigation, a newer request, an AbortController |
(pending) forever | No response yet. The server is hung, or the browser is queueing the request because it already has 6 open connections to that host (the HTTP/1.1 per-host limit) |
Status 0 in a library, or Axios Network Error | No response was received or readable — network failure or CORS. There is no status to read |
CORS hides the real error. If an API returns 500 without CORS headers, the browser reports a CORS error, not the 500. Before "fixing CORS", check the request in the Network tab and in the server logs — the CORS error may be a symptom of a crash on the server.
Part 8: The Status-Code Reflex
| You see | First question | Look at |
|---|---|---|
401 | Was the credential actually sent? | The request's Authorization header or Cookie; token expiry; cookie SameSite/Domain |
403 | Who sent it — my app or a firewall? | Response headers and body; then your permission logic |
404 | Is the URL exactly what I think it is? | The full request URL, base URL, trailing slash, environment, method |
413 | Which layer has the size limit? | Proxy and platform limits before your app |
422 / 400 | What does the error body say? | The validation message in the response |
429 | Who is rate-limiting, and are we retrying too fast? | Retry-After, the rate-limit headers, retry loops |
500 | What did my app log for this request? | App logs, by request ID |
502 | Is the upstream running and accepting connections? | Crashes, restarts, deploys, keep-alive and idle timeouts |
503 | Is anything healthy behind the proxy? | Health checks, instance count, overload |
504 | Why is the upstream slow — and is it still running? | Slow queries, locks, third-party calls, pool exhaustion; idempotency of retries |
52x (Cloudflare) | Why can't the CDN reach my origin? | The origin's health and its connectivity from the CDN |
| CORS error | Is there a real server error behind it? | Server logs, the preflight request, Access-Control-Allow-* headers |
The Causal Chains
- If you get a
502 - If you get a
504 - If a
504is followed by a user retry on a write endpoint - If your app's logs have no record of a failing request
- If you get
401 - If you get
403with an HTML page from your CDN - If a form
POSTarrives at the server as aGETwith no body - If
response.json()throwsUnexpected end of JSON inputon success - If a change on the server is not visible in the browser
- If the browser reports a CORS error
- If you get
431 - If you get
413
The Rule You Keep Forever
A status code is a claim, and the first question is always who made it. Check the headers, the body, the timing and your own logs before deciding the code is about your app — and treat every 504 as "I stopped waiting", never as "nothing happened."
Vocabulary Map
| Term | What it means |
|---|---|
| Status code | A three-digit result at the start of every HTTP response; the first digit is the class |
| Client / origin | Who sends the request / the server that actually owns the content |
| Proxy / reverse proxy | A server that receives and forwards requests on behalf of others |
| Gateway | A proxy acting as the entry point to upstream services |
| Upstream / downstream | Toward the origin / back toward the client, from a given hop's view |
| CDN / edge | A network of servers near users that caches and forwards requests |
| WAF | Web application firewall — rules that block suspicious requests, often at the CDN |
| Authentication / authorisation | Proving who you are (401) / being allowed to do something (403) |
| Redirect | A 3xx response pointing the client to another URL |
| Conditional request | A request asking "has this changed?" using If-None-Match or If-Modified-Since |
ETag | A version identifier for a resource, used for caching and optimistic concurrency |
| Rate limit | A cap on requests per time window; exceeding it returns 429 |
Retry-After | A header saying how long to wait before retrying |
| Idempotent | Safe to repeat — doing it twice has the same effect as once |
| Idempotency key | A client-chosen ID that lets the server recognise a retry of the same operation |
| CORS | The browser's rules for letting a page read responses from another origin |
| Preflight | The OPTIONS request a browser sends before some cross-origin requests to ask permission |
| Mixed content | An https page loading http resources, which browsers block |
| Load shedding | Deliberately rejecting some requests (503) to protect the system under overload |
Practice Problems
Problem 1 — Who sent it? A user reports a 403. The response body is an HTML page with your CDN's branding, and your app's logs have no record of the request. What does this tell you, and what are the next two things you check?
Problem 2 — Fast or slow. Two incidents: in the first, users get 502 within 50 ms; in the second, users get 504 after exactly 60 seconds. For each, give your two leading hypotheses and the first place you look.
Problem 3 — The disappearing body. A signup form works locally. In production, the server receives the request as GET /signup/ with no body. The form posts to /signup. What is happening, and how do you confirm it?
Problem 4 — CORS or crash? The browser shows a CORS error on POST /api/orders, but only for orders with more than 50 items. Smaller orders work. Why is "add the CORS header" probably the wrong first move, and what do you check instead?
Problem 5 — The 504 retry. Your mobile app automatically retries any request that fails with 504. Which endpoints does this make dangerous, and what two changes make the retry safe?
What's Coming Next
Part 1 gave you the language: the method, the errors, the codes, the statuses. Part 2 gives you the instruments. Chapter 5 opens the most-used one — the browser's DevTools — and goes through it the way a senior engineer uses it during an investigation: the Network panel's waterfall and timing breakdown, the request initiator, breakpoints that pause only when something interesting happens, and the Application panel where cookies, storage and service workers quietly cause bugs.