NodeJSConsistent Error Responses

Consistent Error Responses

An API's error responses are part of its public contract — clients write code against them. If every endpoint shapes errors differently (sometimes a string, sometimes { error }, sometimes { message }), clients can't handle failures generically and your API feels unreliable. This page defines a single error envelope, maps failures to the right status codes, and centralizes it all in one error handler.

Pick one error shape and use it everywhere

JSON
// Every error response, from every endpoint, looks like this:
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": [
      { "field": "email", "message": "must be a valid email" }
    ]
  }
}
A stable envelope lets clients handle errors generically
Commit to one structure — here a top-level `error` object with a machine-readable `code`, a human `message`, and optional `details`. Clients can then write *one* error handler: read `error.code` to branch, show `error.message` to users, map `details` to form fields. The [RFC 9457 "Problem Details"](https://www.rfc-editor.org/rfc/rfc9457) format (`type`/`title`/`status`/`detail`) is a good standardized alternative if you want one off the shelf.
Use the status code that fits

Status

Meaning

When

400

Bad Request

Malformed syntax, unparseable JSON

401

Unauthorized

Missing/invalid authentication

403

Forbidden

Authenticated but not allowed

404

Not Found

Resource (or route) does not exist

409

Conflict

Duplicate, version conflict

422

Unprocessable Entity

Well-formed but fails validation

429

Too Many Requests

Rate limit exceeded

500

Internal Server Error

Unexpected server fault

Don't return `200` with an error body
The single most common API mistake: responding `200 OK` with `{ "success": false, "error": "..." }`. The HTTP status code *is* the primary success/failure signal — caches, retries, monitoring, and client libraries all key off it. A "successful" status on a failed request misleads every layer of infrastructure. Use `4xx` for client errors and `5xx` for server errors, always. Read more on [status codes](/nodejs/http-status-codes).
401 vs 403 — a frequent mix-up

401 Unauthorized

403 Forbidden

Means

Who are you? (not authenticated)

I know you, but no.

Fix for client

Log in / send valid credentials

Nothing — you lack permission

Example

No/expired token

User token on an admin route

`401` = unauthenticated, `403` = unauthorized
Despite its name, `401 Unauthorized` actually means *unauthenticated* — "I don't know who you are." `403 Forbidden` means "I know who you are, and you're not allowed." Sending `401` for a permission failure tells the client to re-authenticate, which won't help; sending `403` for a missing token hides that they could fix it by logging in. Map them precisely.
Centralize it in the error handler

One handler shapes every error

JS
class ApiError extends Error {
  constructor(status, code, message, details) {
    super(message)
    this.status = status; this.code = code; this.details = details
  }
}
const badRequest = (msg, details) => new ApiError(400, 'BAD_REQUEST', msg, details)
const notFound   = (msg = 'Not found') => new ApiError(404, 'NOT_FOUND', msg)

// Last middleware — turns any thrown error into the standard envelope:
app.use((err, req, res, next) => {
  if (res.headersSent) return next(err)

  const status = err.status ?? 500
  if (status >= 500) console.error(err)        // log server faults fully

  res.status(status).json({
    error: {
      code: err.code ?? 'INTERNAL_ERROR',
      message: status >= 500 ? 'Internal Server Error' : err.message,
      ...(err.details && { details: err.details }),
    },
  })
})
Hide internal details on `5xx` — leak nothing in production
For server errors, log the full error and stack **server-side**, but send the client only a generic `"Internal Server Error"`. Never expose stack traces, SQL, file paths, or dependency versions in a response — they hand attackers a map of your system. Detailed messages are fine for `4xx` (the client caused it and needs to know), reckless for `5xx`.
Throw typed errors from anywhere

JS
app.get('/orders/:id', async (req, res) => {
  const order = await db.orders.find(req.params.id)
  if (!order) throw notFound('Order not found')          // → 404 envelope
  if (order.userId !== req.user.id) {
    throw new ApiError(403, 'FORBIDDEN', 'Not your order') // → 403 envelope
  }
  res.json(order)
})
Handlers throw; the central handler formats
With typed errors, handlers stay clean — they `throw notFound(...)` and trust the central [error middleware](/nodejs/error-middleware) to render the consistent envelope and status. No more `res.status(404).json(...)` repeated in every handler with slightly different wording. ([Express 5](/nodejs/error-middleware) forwards thrown async errors automatically.)
Also handle 404 for unknown routes

JS
// After all routes, before the error handler — same envelope:
app.use((req, res) => {
  res.status(404).json({
    error: { code: 'NOT_FOUND', message: `Cannot ${req.method} ${req.path}` },
  })
})
Error response checklist
  • One envelope shape across the entire API.

  • A machine-readable code plus a human message.

  • Status code matches the failure (4xx client, 5xx server) — never 200.

  • 401 for unauthenticated, 403 for unauthorized.

  • Field-level details for validation failures (422).

  • Generic message + server-side logging for 5xx; no internals leaked.

Next
Handle large collections gracefully: [Pagination, Filtering & Sorting](/nodejs/pagination).