Inconsistent error handling is where APIs quietly rot: one endpoint returns a string, another a { message }, a third a 200 with success: false. Every client then writes bespoke handling for each. The fix is boring and powerful — one envelope, one middleware, one shape — and it takes about an afternoon to retrofit.
One error envelope
Decide the response shape once and use it everywhere. A good envelope is machine-readable and human-readable, with room for field-level detail:
// the same shape on every endpoint, every status code
{
"error": {
"code": "VALIDATION_ERROR", // stable, machine-readable
"message": "Some fields are invalid.",
"fields": [ // present only for validation errors
{ "field": "email", "message": "Enter a valid email." }
],
"requestId": "req_8f3ac21" // ties the failure to your logs
}
}
The code lets clients branch without string-matching the message; the message is safe to show a user; requestId turns a vague bug report into the exact server trace.
Map errors to the right status code
The status code is the primary signal — get it right and clients, CDNs, and monitoring all behave. Throw typed errors in your code and translate them in one place:
// a small error class carries the status with it
class AppError extends Error {
constructor(status, code, message) {
super(message)
this.status = status
this.code = code
}
}
// usage, deep in a handler
if (!user) throw new AppError(404, "USER_NOT_FOUND", "No user with that id.")
Standard mapping: 400 bad input, 401 not authenticated, 403 authenticated but not allowed, 404 missing, 409 conflict, 422 validation, 429 rate-limited, 500 unexpected.
One middleware to catch it all
Route every error through a single final handler. It's the only place that formats the envelope, so the shape can never drift:
// the last middleware — express calls it for anything that throws
app.use((err, req, res, next) => {
const status = err.status || 500
// log the real error server-side, with the id we return to the client
logger.error({ err, requestId: req.id })
res.status(status).json({
error: {
code: err.code || "INTERNAL_ERROR",
message: status === 500 ? "Something went wrong." : err.message,
requestId: req.id,
},
})
})
Note the 500 branch: known errors show their message; unknown ones show a generic message while the real details go to the logs only.
Never leak internals
The fastest way to hand an attacker a map of your system is a stack trace in a response.
- No stack traces, SQL, or file paths in any client-facing error.
- Generic messages for 5xx — "Something went wrong" plus a
requestId, nothing more. - Specific messages for 4xx — the client caused it, so tell them what to fix.
- Log the full error server-side, keyed by
requestId, so you lose no detail.
Three pieces — one envelope, typed errors mapped to status codes, and a single catch-all middleware — and every endpoint speaks the same error language. Clients write one handler, and support can trace any failure to the line that threw it.
FAQ
What should an API error response contain?
A stable envelope: a machine-readable code, a human-readable message, and — for validation — per-field detail. Add a requestId that also appears in your logs so a user report maps to an exact trace. Never include stack traces or SQL.
Should I return 200 with an error in the body?
No. Use the status code as the primary signal — 4xx for client mistakes, 5xx for server faults. Returning 200 with an error body breaks caching, monitoring, and every client's error handling.
How do I handle unexpected errors safely?
Catch everything in one final error middleware. Map known error types to specific status codes; for anything unrecognized, return a generic 500 with a safe message and log the real error server-side with the requestId.

