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
| Property | Why it matters | Example |
|---|---|---|
| Same structure in every API | One piece of frontend error handling covers every API | Always { type, title, status, detail, ... } |
| Machine-readable code | Branching logic doesn't break when the wording changes | "code": "email_taken" |
| Human-readable message | Developer debugging, and text safe to show as-is | "detail": "This email is already registered." |
| Per-field error list | Forms can mark exactly which inputs are wrong | "errors": [{ "field": "email", ... }] |
| Trace ID | When a user reports a problem, you can find it in the server logs immediately | "traceId": "8f2c..." |
| No internals | Stack 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"
}
| Field | Meaning |
|---|---|
| type | A URI identifying the kind of error. Ideally it points to a page documenting it ("about:blank" if none) |
| title | A short summary of the error kind. Always the same text for the same type |
| status | The HTTP status code (matching the response's status) |
| detail | A specific explanation of this occurrence |
| instance | The 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:
| Request | Response |
|---|---|
| Email kim, password 123 | 422, both fields in errors |
| An already-registered email | 409, code: "email_taken" |
| Broken JSON | 400, code: "invalid_json" |
| An unexpected error like a failed DB connection | 500, a generic message + traceId (the DB address only in the server log) |
| A valid request | 201 + 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
| Principle | How |
|---|---|
| Same shape across every API | RFC 9457 Problem Details (application/problem+json) |
| Something programs can branch on | A stable code (never branch on message strings) |
| An explanation for people | title (the kind), detail (this occurrence) |
| Form validation failures | An errors array, all fields at once |
| Support requests | A traceId linking to server logs |
| Security | Generic messages for unexpected errors, details only in logs |
| Staying consistent | One 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.