📐 Error Response Strategies
You've learned to catch errors — now let's design what the client actually receives. A great error response is a contract: predictable in shape, correct in its status code, and stable enough that frontend and mobile teams can build against it. This lesson turns your handler's output into something a whole ecosystem can trust.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Design a consistent error envelope every endpoint returns
- Choose the correct HTTP status code for each failure category
- Add stable, machine-readable error codes that survive message rewording
- Return structured validation details a form can map field-by-field
- Apply the RFC 9457 Problem Details standard and consume errors well on the client
Estimated Time: 35–45 minutes • Difficulty: Intermediate
Hands-on: Design and implement a reusable error envelope, then write client code that reacts to error.code rather than parsing messages.
In This Lesson
Why the Shape Matters
When errors are shaped differently on every endpoint, the cost lands on whoever consumes your API. One route returns { error: "bad" }, another { message: "...", errors: [...] }, a third a bare string. The frontend ends up with a tangle of special cases, and every new error is a small integration project.
A single, documented shape flips that. The client writes one error path, handles one structure, and reacts to fields it can count on being there. Your API becomes predictable — the highest compliment an API can earn.
💡 Think of it as a contract. Success responses have a shape you promise to keep. Errors deserve the same promise. The status code says what kind of failure; the body says which failure and why.
A Consistent Envelope
Pick one envelope and use it everywhere. A widely used shape wraps the failure under a top-level error object with a small, stable set of keys:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "The email address is not valid.",
"requestId": "8f3c1e0a-5b2d-4c9e-a1f6-0d7b2c4e9a11",
"details": {
"email": "Must be a valid email address"
}
}
}
📖 Each field earns its place
code — a stable identifier the client switches on (never reword it lightly).
message — human-readable text for logs and, sometimes, display.
requestId — correlates the response with your server logs for support.
details — optional, structured extra data (for example, per-field validation errors).
Because you already funnel everything through one central handler, you enforce this shape in exactly one place:
// middleware/errorHandler.js
const crypto = require('node:crypto');
const isProduction = process.env.NODE_ENV === 'production';
function errorHandler(err, req, res, next) {
const status = err.statusCode || 500;
const isOperational = err.isOperational === true;
const requestId = req.id || crypto.randomUUID();
const body = {
success: false,
error: {
code: err.code || 'INTERNAL_ERROR',
message: (!isOperational && isProduction) ? 'Something went wrong' : err.message,
requestId
}
};
if (err.details) body.error.details = err.details;
res.status(status).json(body);
}
module.exports = errorHandler;
Choosing Status Codes
The HTTP status code is the first thing a client, proxy, or cache reads. Get the class right — 4xx means "you (the caller) did something wrong," 5xx means "we (the server) failed" — then pick the specific code.
| Code | Meaning | Use when |
|---|---|---|
| 400 | Bad Request | Malformed input or failed validation |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but not permitted |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Duplicate, or state conflict (e.g. email taken) |
| 422 | Unprocessable Entity | Well-formed but semantically invalid (some teams prefer this over 400) |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | An unexpected bug on the server |
| 503 | Service Unavailable | A dependency (DB, upstream) is down |
⚠️ The classic mistakes
- Returning
200 OKwith{ "success": false }— caches and clients treat it as success. - Confusing 401 (not authenticated) with 403 (authenticated, not allowed).
- Using 500 for a user's bad input — that's a 4xx, and it shouldn't page your on-call engineer.
Stable Error Codes
Status codes are coarse — many different failures all map to 400. A machine-readable code string tells the client which 400 it is, without parsing the human message (which you'll want to reword or translate over time).
// config/errorCodes.js — one source of truth
module.exports = Object.freeze({
VALIDATION_ERROR: 'VALIDATION_ERROR',
NOT_FOUND: 'NOT_FOUND',
AUTHENTICATION_ERROR: 'AUTHENTICATION_ERROR',
AUTHORIZATION_ERROR: 'AUTHORIZATION_ERROR',
DUPLICATE_FIELD: 'DUPLICATE_FIELD',
RATE_LIMITED: 'RATE_LIMITED',
INTERNAL_ERROR: 'INTERNAL_ERROR'
});
✅ Why codes beat messages
A client that checks error.code === 'DUPLICATE_FIELD' keeps working even after you change the message from "Email already exists" to "That address is already registered." Messages are for humans and can be localized; codes are for programs and must stay stable. Document every code you publish.
Attach codes at the source through your AppError classes, so they're consistent and never invented ad hoc in a route:
const CODES = require('../config/errorCodes');
class ConflictError extends AppError {
constructor(message = 'Resource already exists') {
super(message, 409, CODES.DUPLICATE_FIELD);
}
}
Structured Validation Details
Validation is the one case where a single message isn't enough — a form needs to know which fields failed and why, so it can highlight each input. Return that as structured details:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Some fields need your attention.",
"details": {
"email": "Must be a valid email address",
"password": "Must be at least 8 characters"
}
}
}
Producing it is straightforward when you collect field errors into an object and throw a typed error:
const { ValidationError } = require('../errors');
function validateSignup(body) {
const details = {};
if (!body.email || !/.+@.+\..+/.test(body.email)) {
details.email = 'Must be a valid email address';
}
if (!body.password || body.password.length < 8) {
details.password = 'Must be at least 8 characters';
}
if (Object.keys(details).length > 0) {
throw new ValidationError('Some fields need your attention.', details);
}
}
app.post('/signup', catchAsync(async (req, res) => {
validateSignup(req.body);
const user = await userService.create(req.body);
res.status(201).json({ success: true, data: user });
}));
💡 A shape decision to make once
Some teams prefer details as an object keyed by field (easy for forms to look up); others use an array of { field, message } (easy to iterate and to allow multiple errors per field). Either works — just pick one and use it across every endpoint.
RFC 9457 Problem Details
If you'd rather adopt a standard than invent your own envelope, RFC 9457 — Problem Details for HTTP APIs (which obsoletes the older RFC 7807) defines exactly this. Responses use the media type application/problem+json and a small set of standard members:
{
"type": "https://example.com/probs/out-of-credit",
"title": "You do not have enough credit.",
"status": 403,
"detail": "Your balance is 30, but the item costs 50.",
"instance": "/account/12345/transactions/abc"
}
| Member | Meaning |
|---|---|
type | A URI identifying the problem type (doubles as documentation) |
title | Short, human-readable summary of the type |
status | The HTTP status code, repeated in the body |
detail | Explanation specific to this occurrence |
instance | URI identifying this specific occurrence |
// Emitting Problem Details from your handler
function problemHandler(err, req, res, next) {
const status = err.statusCode || 500;
res
.status(status)
.type('application/problem+json')
.json({
type: err.type || 'about:blank',
title: err.title || err.message,
status,
detail: err.detail || err.message,
instance: req.originalUrl
});
}
💡 Standard or custom?
RFC 9457 is a great default for public or partner-facing APIs — clients may already understand it. For an internal API, a well-documented custom envelope is perfectly fine. What matters most is that you pick one and apply it everywhere; you can add your own members (like a stable code) to a Problem Details body too.
Consuming Errors on the Client
A consistent envelope only pays off if the client uses it consistently. Centralize error handling there too — react to the code, surface details on forms, and never parse the human message to decide behavior.
async function apiRequest(url, options) {
const res = await fetch(url, options);
const body = await res.json();
if (!res.ok) {
const { code, message, details } = body.error ?? {};
switch (code) {
case 'AUTHENTICATION_ERROR':
redirectToLogin();
break;
case 'VALIDATION_ERROR':
showFieldErrors(details); // map field -> input
break;
case 'RATE_LIMITED':
showToast('Slow down a moment and try again.');
break;
default:
showToast(message || 'Something went wrong.');
}
throw Object.assign(new Error(message), { code, details, status: res.status });
}
return body.data;
}
✅ The whole strategy in one sentence
The server promises a stable shape and a stable code; the client switches on that code in one place. Rewordings, translations, and new messages never break the integration.
Hands-on Exercise
🏋️ Design and Serve a Consistent Envelope
Objective: Implement the standard envelope for two endpoints and write client code that reacts to error.code, not the message.
Instructions:
- Define an error-code constants object with at least
VALIDATION_ERRORandNOT_FOUND. - Add
GET /items/:idthat returns a 404 with codeNOT_FOUNDwhen the id isn't"1". - Add
POST /itemsthat returns a 400 with codeVALIDATION_ERRORand per-fielddetailswhennameis missing. - Route both through one central handler that emits the
{ success, error: { code, message, details } }envelope. - Write a small
fetchwrapper that switches onerror.code.
💡 Hint
Throw typed errors that carry statusCode, code, and optional details; let the single handler build the envelope. The client should never look at error.message to decide what to do — only to display as a fallback.
✅ Solution
const express = require('express');
const app = express();
app.use(express.json());
const CODES = Object.freeze({ VALIDATION_ERROR: 'VALIDATION_ERROR', NOT_FOUND: 'NOT_FOUND' });
class AppError extends Error {
constructor(message, statusCode, code, details) {
super(message);
this.statusCode = statusCode; this.code = code;
this.details = details; this.isOperational = true;
}
}
app.get('/items/:id', (req, res, next) => {
if (req.params.id !== '1') return next(new AppError('Item not found', 404, CODES.NOT_FOUND));
res.json({ success: true, data: { id: 1, name: 'Widget' } });
});
app.post('/items', (req, res, next) => {
if (!req.body?.name) {
return next(new AppError('Some fields need attention.', 400, CODES.VALIDATION_ERROR,
{ name: 'Name is required' }));
}
res.status(201).json({ success: true, data: { id: 2, name: req.body.name } });
});
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
const body = { success: false, error: { code: err.code || 'INTERNAL_ERROR', message: err.message } };
if (err.details) body.error.details = err.details;
res.status(status).json(body);
});
app.listen(3000, () => console.log('http://localhost:3000'));
// --- client wrapper ---
async function apiRequest(url, options) {
const res = await fetch(url, options);
const body = await res.json();
if (!res.ok) {
const { code, message, details } = body.error;
if (code === 'VALIDATION_ERROR') console.log('Field errors:', details);
else if (code === 'NOT_FOUND') console.log('Nothing here.');
else console.log(message);
throw Object.assign(new Error(message), { code, details });
}
return body.data;
}
🎯 Quick Quiz
Question 1: Why should a client switch on error.code rather than error.message?
Question 2: A user is logged in but tries to delete another user's post. Which status code fits best?
Question 3: What is the purpose of details in a validation error response?
Summary & Quiz
🎉 Key Takeaways
- A consistent envelope turns your API into a contract clients can build against once.
- Get the status-code class right first:
4xxfor caller errors,5xxfor server errors. - Add stable, machine-readable codes so behavior survives message rewording and localization.
- Return structured details for validation so forms can map errors field-by-field.
- RFC 9457 Problem Details is a ready-made standard; custom envelopes are fine if documented and consistent.
- Consume errors on the client in one place, switching on
code, not message text.
📚 Further Reading
- RFC 9457 — Problem Details for HTTP APIs
- MDN — HTTP response status codes
- Node.js Best Practices — Error Handling
🚀 What's Next?
You now have the full error toolkit — middleware, async handling, and response design. Next you'll put it all to work in the Weekend Project, building a complete Express API where these patterns come together in one real application.
🎉 You've mastered the error trilogy!
Catch it, handle it async, and shape it into a response clients can trust. Time to build.