DEV Community

Sohail Khan
Sohail Khan

Posted on

Your API Does Not Need More Logs. It Needs One Error Shape.

Your API does not need more logs. It needs one error shape.

A client I worked with kept breaking every time an endpoint failed. One error came back as plain text, the next as an HTML page, the next as a JSON object with a different field name each time. The frontend could not tell a missing order from a server crash, so it showed the same vague message for both.

The frontend team had a different check for every endpoint, and every new endpoint added another guess. Support tickets said the same thing each time: "something went wrong." Nobody could say what, because the API never said what in a shape the client could read.

The wrong fix

The wrong fix is to write a better message for each endpoint.

Better messages in five different shapes are still five different shapes. A clear message inside a shape the client cannot parse is still unreadable to the client. Every new endpoint adds one more shape for the client to guess, and the frontend grows a new special case for each one.

The real fix: one shape for every error

Every error returns the same shape. It has a stable code the client can check and a message a human can read:

return res.status(404).json({
  error: {
    code: 'ORDER_NOT_FOUND',
    message: 'No order exists for this id.',
  },
});
Enter fullscreen mode Exit fullscreen mode

The code never changes for the same failure, and the message never decides what the client does. The client checks error.code, not the message text. A missing order shows a clear message, a server error shows a retry option, and a new endpoint needs no new error handling on the client.

One place that builds the shape

Do not build this object by hand in every route. Build it once, in one helper, and let every route use it:

function sendError(res, status, code, message, details) {
  return res.status(status).json({
    error: {
      code,
      message,
      ...(details ? { details } : {}),
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Then the routes stay short, and the shape stays the same:

app.get('/orders/:id', async (req, res) => {
  const order = await db.orders.findById(req.params.id);
  if (!order) {
    return sendError(res, 404, 'ORDER_NOT_FOUND', 'No order exists for this id.');
  }
  return res.json({ order });
});
Enter fullscreen mode Exit fullscreen mode

Validation errors use the same shape

A validation failure is not a different kind of error. It uses the same shape, with a details field that lists what failed:

return sendError(res, 400, 'VALIDATION_FAILED', 'The order data is not valid.', [
  { field: 'customerId', message: 'Customer id is required.' },
  { field: 'items', message: 'At least one item is required.' },
]);
Enter fullscreen mode Exit fullscreen mode

The client still checks error.code first. The details field only tells the form which fields to highlight.

Server errors use the same shape too

A 500 is the shape doing its most useful work. The client sees a stable code and a safe message, while the real error goes to your logs, not to the response:

app.use((err, req, res, next) => {
  logger.error('unhandled_error', {
    path: req.path,
    message: err.message,
    stack: err.stack,
  });

  return sendError(res, 500, 'INTERNAL_ERROR', 'Something went wrong on our side. Please try again.');
});
Enter fullscreen mode Exit fullscreen mode

The user gets a retry option instead of a stack trace. Support gets a log line with the real cause. The shape is identical in both cases.

What the shape must never do

Two rules keep the shape honest.

First, never change the code for the same failure. If ORDER_NOT_FOUND means a missing order today, it must mean a missing order next year. The moment a code changes meaning, every client check built on it breaks quietly.

Second, never put internal detail in the message. A message that names a table, a query, or a file path hands an attacker a map of your system. The message is for the human in front of the screen. The cause belongs in your logs.

How the client uses it

Once the shape is stable, the client code gets simple. One check, one switch, no guessing:

const res = await fetch(`/orders/${id}`);
if (!res.ok) {
  const body = await res.json();
  if (body.error.code === 'ORDER_NOT_FOUND') {
    showMessage('This order does not exist.');
  } else if (body.error.code === 'INTERNAL_ERROR') {
    showRetry('The server had a problem. Try again.');
  }
  return;
}
Enter fullscreen mode Exit fullscreen mode

A new endpoint needs no new error handling on the client. It returns the same shape, so the client already knows how to read it.


Before you add another log line, check what your errors actually return. How many shapes does your API have today? Drop it in the comments.


I'm Sohail Khan, a Senior Full Stack Developer building AI agents and automation. I write about backend engineering, AI integration and practical system design.

Top comments (0)