Skip to main content

📐 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.
flowchart LR A[Error thrown] --> B[Central Handler] B --> C[Map to status code] B --> D[Attach stable code] B --> E[Add details if relevant] C --> F[Consistent JSON Envelope] D --> F E --> F F --> G[Client reacts on code]

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.

CodeMeaningUse when
400Bad RequestMalformed input or failed validation
401UnauthorizedMissing or invalid authentication
403ForbiddenAuthenticated but not permitted
404Not FoundResource does not exist
409ConflictDuplicate, or state conflict (e.g. email taken)
422Unprocessable EntityWell-formed but semantically invalid (some teams prefer this over 400)
429Too Many RequestsRate limit exceeded
500Internal Server ErrorAn unexpected bug on the server
503Service UnavailableA dependency (DB, upstream) is down

⚠️ The classic mistakes

  • Returning 200 OK with { "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"
}
MemberMeaning
typeA URI identifying the problem type (doubles as documentation)
titleShort, human-readable summary of the type
statusThe HTTP status code, repeated in the body
detailExplanation specific to this occurrence
instanceURI 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:

  1. Define an error-code constants object with at least VALIDATION_ERROR and NOT_FOUND.
  2. Add GET /items/:id that returns a 404 with code NOT_FOUND when the id isn't "1".
  3. Add POST /items that returns a 400 with code VALIDATION_ERROR and per-field details when name is missing.
  4. Route both through one central handler that emits the { success, error: { code, message, details } } envelope.
  5. Write a small fetch wrapper that switches on error.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: 4xx for caller errors, 5xx for 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

🚀 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.