Skip to content
Express.js ex requests 4 min read

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.

CodeMeaningUse it when
200OKA GET/PUT/PATCH succeeded and returns a body
201CreatedA POST created a new resource; include it (or its Location)
204No ContentSuccess with nothing to return, e.g. a DELETE
400Bad RequestMalformed syntax or invalid request the client must fix
401UnauthorizedAuthentication is missing or invalid (you are not logged in)
403ForbiddenAuthenticated but not allowed (you may not do this)
404Not FoundThe requested resource does not exist
422Unprocessable EntitySyntactically valid but fails business/validation rules
500Internal Server ErrorAn unexpected error your code did not handle

Tip: The 401 vs 403 distinction trips up many teams. 401 means who are you? — credentials are absent or wrong. 403 means 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 500 body 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-200 outcome — Express never infers 404 or 500 for you.
  • Use 201 for creates and 204 for empty successes; do not return 200 with an empty body when 204 fits.
  • Distinguish 400 (malformed request), 422 (valid but rejected by rules), 401 (not authenticated), and 403 (not permitted).
  • Always pair res.status(code) with a terminator, and return from guard clauses so you never send twice.
  • Centralize unexpected failures in one error-handling middleware that owns the 500 response.
  • Reserve res.sendStatus for plain replies; use res.status(code).json(...) whenever clients parse the body.
Last updated June 14, 2026
Was this helpful?