Setting HTTP Status Codes
The HTTP status code is the first thing a client reads from your response, and it carries real meaning: it tells browsers, proxies, and API consumers whether a request succeeded, failed, or needs to be retried. Express does not infer the code for you — every successful res.send or res.json defaults to 200, so you must set the right code explicitly whenever the outcome is anything else. Getting this right is what separates a polite REST API from one that lies to its clients.
Setting a code with res.status
res.status(code) sets the numeric status on the response but sends nothing on its own. It returns the res object so you chain a terminator — .json(), .send(), or .end() — onto it. This status-then-body pairing is the idiomatic Express pattern.
const express = require("express");
const app = express();
app.post("/articles", express.json(), async (req, res) => {
if (!req.body.title) {
return res.status(400).json({ error: "title is required" });
}
const article = await db.createArticle(req.body);
res.status(201).json(article); // 201 Created
});
app.listen(3000);
Because res.status() alone leaves the response open and the request hanging, always follow it with a terminator. Note the return in the guard clause: without it, execution would fall through and try to send a second response, throwing ERR_HTTP_HEADERS_SENT.
Status plus reason phrase with res.sendStatus
When you want to reply with just a status code and its standard reason phrase as the body, use res.sendStatus(code). It sets the status, writes the matching text, and ends the response in one call.
app.get("/healthz", (req, res) => {
res.sendStatus(200); // status 200, body "OK"
});
app.use((req, res) => {
res.sendStatus(404); // status 404, body "Not Found"
});
Output:
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Not Found
Use res.sendStatus for trivial replies (health checks, no-content acknowledgements rendered as text). For anything where a client parses the body, prefer res.status(code).json(...) so consumers get structured data rather than a plain string.
The codes you actually use
You do not need all of HTTP’s status registry — a small, well-chosen set covers nearly every API. The table below maps each common code to the outcome it represents.
| Code | Meaning | Use it when |
|---|---|---|
200 | OK | A GET/PUT/PATCH succeeded and returns a body |
201 | Created | A POST created a new resource; include it (or its Location) |
204 | No Content | Success with nothing to return, e.g. a DELETE |
400 | Bad Request | Malformed syntax or invalid request the client must fix |
401 | Unauthorized | Authentication is missing or invalid (you are not logged in) |
403 | Forbidden | Authenticated but not allowed (you may not do this) |
404 | Not Found | The requested resource does not exist |
422 | Unprocessable Entity | Syntactically valid but fails business/validation rules |
500 | Internal Server Error | An unexpected error your code did not handle |
Tip: The
401vs403distinction trips up many teams.401means who are you? — credentials are absent or wrong.403means I know who you are, and you still can’t — the user is authenticated but lacks permission.
Matching codes to outcomes
The cleanest handlers read like a decision tree where each branch ends in a status that matches its outcome. Validation failures, missing resources, and successes each get their own code.
const router = express.Router();
router.put("/users/:id", express.json(), async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) {
return res.status(404).json({ error: "user not found" });
}
if (req.user.id !== user.id && !req.user.isAdmin) {
return res.status(403).json({ error: "not allowed" });
}
const errors = validate(req.body);
if (errors.length) {
return res.status(422).json({ errors });
}
const updated = await db.updateUser(user.id, req.body);
res.status(200).json(updated);
});
module.exports = router;
Creation and empty responses
A successful POST should return 201 and, ideally, the created resource plus a Location header pointing at it. A successful DELETE that returns no body should be 204.
router.post("/articles", express.json(), async (req, res) => {
const article = await db.createArticle(req.body);
res
.status(201)
.location(`/articles/${article.id}`)
.json(article);
});
router.delete("/articles/:id", async (req, res) => {
await db.deleteArticle(req.params.id);
res.status(204).end(); // no body
});
Errors from a central handler
Let unexpected failures bubble to an error-handling middleware so you set 500 in exactly one place rather than wrapping every route in try/catch. In Express 5, errors thrown from async handlers are forwarded to this middleware automatically; in 4.x you must next(err) yourself.
// Express 5: async errors reach this automatically
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status || 500).json({ error: "internal server error" });
});
Warning: Never leak raw error messages or stack traces in a
500body in production. Log the detail server-side and return a generic message; attackers read verbose errors for clues.
Best Practices
- Set the status explicitly for every non-
200outcome — Express never infers404or500for you. - Use
201for creates and204for empty successes; do not return200with an empty body when204fits. - Distinguish
400(malformed request),422(valid but rejected by rules),401(not authenticated), and403(not permitted). - Always pair
res.status(code)with a terminator, andreturnfrom guard clauses so you never send twice. - Centralize unexpected failures in one error-handling middleware that owns the
500response. - Reserve
res.sendStatusfor plain replies; useres.status(code).json(...)whenever clients parse the body.