Clients, caches, monitoring and search engines all act on the status code before they look at the body.
The three-digit status code summarises the outcome; the first digit gives the class.
2xx success:
200 OK,201 Created(with aLocationheader pointing at the new resource),202 Accepted(queued for later processing),204 No Content(success, empty body).
3xx redirection:
301 Moved Permanentlyand308 Permanent Redirect;302 Foundand307 Temporary Redirect. The difference between the pairs is that 307 and 308 guarantee the method and body are kept, while clients may turn a redirected POST into a GET after 301/302.303 See Other: go GET this other URL, the classic redirect after a form POST.304 Not Modified: your cached copy is still valid.
4xx client errors (the request needs fixing):
400 Bad Request,401 Unauthorized(actually "unauthenticated": no or bad credentials),403 Forbidden(authenticated but not allowed),404 Not Found,405 Method Not Allowed,409 Conflict,410 Gone,415 Unsupported Media Type,422 Unprocessable Content(well-formed but invalid data),429 Too Many Requests.
5xx server errors (the server failed):
500 Internal Server Error,502 Bad Gatewayand504 Gateway Timeout(a proxy couldn't get a good or timely answer from upstream),503 Service Unavailable(overloaded or in maintenance, often withRetry-After).
Choose precisely: clients retry 503 and 429, never 400; monitoring alerts on 5xx; search engines drop pages that return 404 or 410. Returning 200 with an error message in the body breaks all of that.
Example
import http from "node:http";
const server = http.createServer((req, res) => {
if (req.url === "/old") { res.writeHead(301, { Location: "/new" }); return res.end(); }
if (req.url === "/new") { res.writeHead(200, { "Content-Type": "text/plain" }); return res.end("hi"); }
if (req.url === "/admin") { res.writeHead(403); return res.end(); }
res.writeHead(404, { "Content-Type": "text/plain" }); res.end("not found");
});
server.listen(0, async () => {
const base = `http://localhost:${server.address().port}`;
for (const p of ["/old", "/admin", "/nope"]) {
const r = await fetch(base + p, { redirect: "manual" });
console.log(p, r.status, r.headers.get("location"));
}
// /old 301 /new
// /admin 403 null
// /nope 404 null
server.close();
});Remember
- 2xx success, 3xx redirect, 4xx client error, 5xx server error
- 401 = not authenticated, 403 = authenticated but not allowed
- 307/308 keep the method; 303 switches to GET
- Never return 200 for an error
Watch out for
- Returning 200 with {"error": ...} in the body, which hides failures from clients, caches and monitoring.