A status code is the one part of a response that every client, proxy, cache, load balancer and monitoring dashboard reads without understanding your API. A retry library doesn’t parse your error JSON; it looks at the number. A CDN decides what to cache from it. An alerting rule counts 5xx responses. So when an interviewer asks “what status code would you return here?”, the question underneath is: what do you want the software on the other end to do next?
This note treats status codes as decisions, with a small client that maps each code to an action and a local server that returns them.
Five classes, five instructions
The first digit carries most of the meaning, and RFC 9110 tells clients that see an unfamiliar code to treat it like the x00 of its class. A client that has never heard of 429 still knows it’s a client-side problem.
- 1xx, informational. Interim responses:
100 Continuebefore a large upload,101 Switching Protocolsfor a WebSocket upgrade,103 Early Hintsso a browser can start loading assets early. - 2xx, success. The request did what it asked.
201 Createdshould come with aLocationheader;204 No Contenthas no body. - 3xx, look elsewhere. Follow the
Locationheader, or for304 Not Modified, reuse the copy you already have. - 4xx, the client must change something. Sending the same request again will get the same answer, with a few exceptions covered below.
- 5xx, the server failed. The request may have been perfectly fine.
A client that decides from the status
Here is a decision function of the kind every API client should have somewhere, even if it’s spread across a retry library and an auth interceptor:
const IDEMPOTENT = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
// What should a client do with this response?
function decide(res, method) {
const s = res.status;
const retryAfter = res.headers.get('retry-after');
if (s >= 200 && s < 300) return 'success';
if (s === 304) return 'use the cached copy';
if (s >= 300 && s < 400) return `follow ${res.headers.get('location')}`;
if (s === 401) return 'refresh credentials, retry once';
if (s === 408 || s === 429 || s === 503) return `not processed: retry after ${retryAfter ?? 'a backoff'}${retryAfter ? 's' : ''}`;
if (s >= 400 && s < 500) return 'do not retry: fix the request';
// 500, 502, 504: the server or something behind it may have acted
return IDEMPOTENT.has(method) ? 'retry with backoff' : 'outcome unknown: check first';
}A local Node server returned each code (with Retry-After: 2 on the 429 and Retry-After: 30 on the 503), and the client fetched each one with redirect: 'manual':
200 ok=true GET: success POST: success
201 ok=true GET: success POST: success
301 ok=false GET: follow /new-home POST: follow /new-home
304 ok=false GET: use the cached copy POST: use the cached copy
400 ok=false GET: do not retry: fix the request POST: do not retry: fix the request
401 ok=false GET: refresh credentials, retry once POST: refresh credentials, retry once
403 ok=false GET: do not retry: fix the request POST: do not retry: fix the request
404 ok=false GET: do not retry: fix the request POST: do not retry: fix the request
409 ok=false GET: do not retry: fix the request POST: do not retry: fix the request
422 ok=false GET: do not retry: fix the request POST: do not retry: fix the request
429 ok=false GET: not processed: retry after 2s POST: not processed: retry after 2s
500 ok=false GET: retry with backoff POST: outcome unknown: check first
502 ok=false GET: retry with backoff POST: outcome unknown: check first
503 ok=false GET: not processed: retry after 30s POST: not processed: retry after 30s
504 ok=false GET: retry with backoff POST: outcome unknown: check firstLook at the ok column first. fetch resolved for every one of these, including 404 and 500. It only rejects when there’s no HTTP response at all, such as a DNS failure or a refused connection. A surprising amount of front-end code does const data = await (await fetch(url)).json() and then fails in confusing ways when the body is an error page. Check res.ok or the status before using the body.
Then the interesting split: the 4xx codes that are worth retrying, and the 5xx codes where it depends on the method.
- 429 Too Many Requests and 503 Service Unavailable both mean the server declined to handle the request, usually with a
Retry-Aftersaying when to come back. Nothing happened, so even a POST can be retried, after the delay. Retrying immediately makes the overload worse. - 408 Request Timeout means the server gave up waiting for the client to finish sending. It also wasn’t processed.
- 500, 502 and 504 are different. A 500 might come after the database write committed and before the response was built. A 504 from a gateway means the upstream didn’t answer in time, which says nothing about whether it finished the work. For idempotent methods, retry with backoff. For a POST, the outcome is unknown, so retry only with an idempotency key, or check the resource’s state first.
Validation errors belong to the client
A common question, and a common production bug: what should an API return when the request is well-formed JSON but breaks a business rule, such as an order quantity of zero?
Not 500. A 500 tells every generic client that the server is broken and the request was fine. Retry libraries retry it. Monitors page someone. The user sees “something went wrong” when the real message was “quantity must be at least 1”.
The usual split is 400 for a request the server can’t parse at all and 422 for one it understood but can’t accept. Here’s a local endpoint doing both:
curl -si -X POST http://127.0.0.1:8090/orders -d '{"qty": 0}'HTTP/1.1 422 Unprocessable Entity
content-type: application/problem+json
{"title":"Order is invalid","status":422,"errors":[{"field":"qty","detail":"must be a whole number of at least 1"},{"field":"sku","detail":"is required"}]}curl -si -X POST http://127.0.0.1:8090/orders -d '{"qty": 1,'HTTP/1.1 400 Bad Request
content-type: application/problem+json
{"title":"Malformed JSON","status":400}Some teams use 400 for both, which is fine as long as it’s consistent and the body explains what’s wrong. The application/problem+json format from RFC 9457 gives clients a standard shape to parse. One small detail: RFC 9110 renamed 422 to “Unprocessable Content”, but Node still prints the old reason phrase. Clients should never depend on the phrase anyway.
The codes people mix up
401 versus 403. 401 means “I don’t know who you are”: credentials are missing, expired or invalid. It must include a WWW-Authenticate header, and the right reaction is to refresh a token or log in again. 403 means “I know who you are, and you’re not allowed”. Logging in again won’t help. Some APIs return 404 instead of 403 so they don’t reveal that a resource exists.
409 versus 412. 409 Conflict means the request clashes with the current state, such as creating a username that’s taken. 412 Precondition Failed is specifically a failed If-Match or If-Unmodified-Since check, the standard signal for “someone else changed this since you read it”.
502, 503 and 504. All three can come from a load balancer or gateway. 502 means the upstream sent back something invalid, or the connection broke. 503 means no one is available to handle the request right now: overloaded, in maintenance, no healthy targets. 504 means the upstream didn’t respond in time. They point at different fixes: a crashing process, a capacity problem, or a slow dependency.
301 and 302 versus 307 and 308. For historical reasons, clients may change a POST into a GET when following 301 or 302. 307 and 308 guarantee the method and body are kept. Use 308 for a permanent move of an API endpoint that receives POSTs.
Finally, the anti-pattern: returning 200 OK with {"error": "not found"} in the body. Monitors count it as success, caches may store it, and every client has to parse the body to learn the request failed.
Interview practice
A partner’s integration uses a retry library that retries any non-2xx response up to five times. After one of your deploys, a bug made the order endpoint return 500 for every order that failed validation. The partner’s retries multiplied the traffic, and your on-call engineer was paged for an outage that wasn’t really one. What went wrong, and what would you change on both sides?
Answer
On our side, validation failures were reported as server errors. A 500 told the partner’s library, our dashboards and our alerting that the server was broken and the request was fine, so the library retried requests that could never succeed and alerting fired on what were really bad inputs. I’d return 422 (or 400) with a problem-details body that names each invalid field, add a test that asserts the status for invalid input, and alert on 5xx rates that exclude known client errors.
On the partner’s side, retrying every non-2xx is too broad. I’d recommend retrying only 408, 429 and 503 (honouring Retry-After), plus 500, 502 and 504 for idempotent requests or requests carrying an idempotency key, with exponential backoff and jitter and a total time budget. Any other 4xx should go straight back to their code or their user, because sending the same invalid order five more times can’t make it valid. To protect ourselves regardless of client behaviour, I’d add rate limiting that returns 429 with Retry-After.