Designing API Error Responses: Unifying Inconsistent Errors with RFC 9457 Problem Details

If one API returns a string, another returns { error }, and a third returns { message, code }, the frontend needs different code for every error. What makes a good error response, the standard RFC 9457 Problem Details format, per-field validation errors, hiding internals, and handling it all in one place in Node.js.

When every error has a different shape

Say the APIs in one service return errors like these:

// Login API
"Wrong password"

// Sign-up API
{ "error": "duplicate email" }

// Posts API
{ "success": false, "msg": "no permission", "errCode": 1003 }

// Payments API
{ "message": "Error: Cannot read properties of undefined (reading 'card')" }

The frontend needs separate code to pull the error out of each API, and some errors are internal messages you can't show to users. Once HTTP status codes tell clients "what kind of failure" happened, it's the body's turn to say "what failed, why, and how" in a consistent shape.

A good error response has the same shape across every API, carries both a code programs can branch on and a message people can read, and leaves out internals.


What makes a good error response

PropertyWhy it mattersExample
Same structure in every APIOne piece of frontend error handling covers every APIAlways { type, title, status, detail, ... }
Machine-readable codeBranching logic doesn't break when the wording changes"code": "email_taken"
Human-readable messageDeveloper debugging, and text safe to show as-is"detail": "This email is already registered."
Per-field error listForms can mark exactly which inputs are wrong"errors": [{ "field": "email", ... }]
Trace IDWhen a user reports a problem, you can find it in the server logs immediately"traceId": "8f2c..."
No internalsStack traces, SQL, and internal IPs are clues for attackers❌ "Cannot read properties of undefined"

Don't make the frontend branch on message strings. Code like if (error.message === "This email is already registered.") breaks the moment someone rewords or translates the message. Always branch on a stable code.


There's a standard: RFC 9457 Problem Details

You don't need to invent an error format from scratch. There's a standard for HTTP API error responses: RFC 9457 (Problem Details for HTTP APIs), which replaced the earlier RFC 7807 in 2023.

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/email_taken",
  "title": "Email already in use",
  "status": 409,
  "detail": "taken@example.com is already registered.",
  "instance": "/users"
}
FieldMeaning
typeA URI identifying the kind of error. Ideally it points to a page documenting it ("about:blank" if none)
titleA short summary of the error kind. Always the same text for the same type
statusThe HTTP status code (matching the response's status)
detailA specific explanation of this occurrence
instanceThe request path or identifier where the problem occurred

Use application/problem+json as the Content-Type. Beyond the standard fields you can freely add extension members, so fields like code, errors, and traceId are fine.

Major frameworks support this format out of the box, like Spring's (Java) ProblemDetail and ASP.NET Core's ProblemDetails. Following the standard instead of inventing an in-house convention means other teams and outside developers understand it immediately.


Validation errors: report them per field

Several fields can fail validation at once. If you only return the first error, users get stuck in a loop of fixing, submitting, and seeing the next error. Report every invalid field at once.

{
  "type": "https://api.example.com/errors/validation_failed",
  "title": "Invalid input",
  "status": 422,
  "detail": "Please check 2 fields.",
  "instance": "/users",
  "code": "validation_failed",
  "errors": [
    { "field": "email", "code": "invalid_format", "message": "Email format is invalid." },
    { "field": "password", "code": "too_short", "message": "Password must be at least 8 characters." }
  ]
}

The frontend can loop over errors and show each message under its input. For a multilingual service, use code (too_short) as the translation key instead of message.


Handle it in one place on the server (Node.js)

If every API builds its own error responses, the shapes drift apart. Keep one error class and one function that sends error responses, and route every error through them.

import { randomUUID } from "node:crypto";

// 1. A class for expected errors (ones it's safe to tell the client about)
class ApiError extends Error {
  constructor(status, code, title, detail, extra = {}) {
    super(detail);
    this.status = status;
    this.code = code;
    this.title = title;
    this.extra = extra;
  }
}

// 2. The single place that turns any error into Problem Details
function sendProblem(req, res, err) {
  const traceId = randomUUID();

  let body;
  if (err instanceof ApiError) {
    body = {
      type: `https://api.example.com/errors/${err.code}`,
      title: err.title,
      status: err.status,
      detail: err.message,
      instance: req.url,
      code: err.code,
      traceId,
      ...err.extra,
    };
  } else {
    // Unexpected error: keep the details in the server log only
    console.error(`[${traceId}]`, err);
    body = {
      type: "about:blank",
      title: "Internal Server Error",
      status: 500,
      detail: "Something went wrong while processing the request.",
      instance: req.url,
      traceId,
    };
  }

  res.statusCode = body.status;
  res.setHeader("Content-Type", "application/problem+json");
  res.end(JSON.stringify(body));
}

Business logic just throws the right ApiError.

async function createUser(input) {
  const errors = [];
  if (!isEmail(input.email)) {
    errors.push({ field: "email", code: "invalid_format", message: "Email format is invalid." });
  }
  if ((input.password ?? "").length < 8) {
    errors.push({ field: "password", code: "too_short", message: "Password must be at least 8 characters." });
  }
  if (errors.length > 0) {
    throw new ApiError(422, "validation_failed", "Invalid input",
      `Please check ${errors.length} fields.`, { errors });
  }

  if (await emailExists(input.email)) {
    throw new ApiError(409, "email_taken", "Email already in use",
      `${input.email} is already registered.`);
  }
  // ...
}

Sending real requests through this setup gives:

RequestResponse
Email kim, password 123422, both fields in errors
An already-registered email409, code: "email_taken"
Broken JSON400, code: "invalid_json"
An unexpected error like a failed DB connection500, a generic message + traceId (the DB address only in the server log)
A valid request201 + Location

With Express, register sendProblem as error-handling middleware ((err, req, res, next) => ...) and you get the same structure.


Consuming it on the frontend

Since every error has the same shape, one piece of handling code is enough.

async function api(url, options) {
  const res = await fetch(url, options);
  if (res.ok) return res.status === 204 ? null : res.json();

  const problem = await res.json().catch(() => ({ status: res.status, title: res.statusText }));
  const error = new Error(problem.detail ?? problem.title);
  error.problem = problem;
  throw error;
}

try {
  await api("/users", { method: "POST", body: JSON.stringify(form) });
} catch (e) {
  const p = e.problem;
  if (p?.code === "validation_failed") {
    p.errors.forEach(({ field, message }) => showFieldError(field, message));
  } else if (p?.code === "email_taken") {
    showFieldError("email", p.detail);
  } else {
    toast(`Something went wrong. (Reference: ${p?.traceId ?? "none"})`);
  }
}

When a user gives support the traceId, you can find the actual error for that request in the server logs right away.


Summary: why this is worth knowing

PrincipleHow
Same shape across every APIRFC 9457 Problem Details (application/problem+json)
Something programs can branch onA stable code (never branch on message strings)
An explanation for peopletitle (the kind), detail (this occurrence)
Form validation failuresAn errors array, all fields at once
Support requestsA traceId linking to server logs
SecurityGeneric messages for unexpected errors, details only in logs
Staying consistentOne error class + one error response function

If status codes say "what kind of failure," the error body says "so what should I do about it." Settle the format once, and as your API grows, the frontend's error handling code doesn't.