DEV Community

Cover image for Stop Returning `200 OK` for Every API Response in Express
Loknath Patel
Loknath Patel

Posted on AI-assisted

Stop Returning `200 OK` for Every API Response in Express

A common pattern in Express tutorials is to end every route with res.json(...) and report failures inside the response body:

app.get('/users/:id', async (req, res) => {
  const user = await db.findUser(req.params.id);

  if (!user) {
    return res.json({
      success: false,
      message: 'User not found'
    });
  }

  res.json({ success: true, user });
});
Enter fullscreen mode Exit fullscreen mode

The problem is that Express sends 200 OK by default when you don't set another status code. The HTTP response says the request succeeded, while the body says it failed.

That mismatch makes your API harder to use correctly.

The status code is part of your API contract

Browsers, HTTP clients, proxies, monitoring tools and application logs can all work with HTTP status codes. They don't automatically understand a custom field such as success: false.

Consider the Fetch API. A request that receives a 404 or 500 doesn't normally reject the fetch() promise. You check response.ok to determine whether the response has a successful HTTP status.

If every response returns 200, response.ok stays true, even when the body describes a failure. Every client has to remember to inspect your custom response format, and one missed check can lead to incorrect behaviour.

Using the right status code makes the outcome clear at the HTTP level.

Success responses: 200, 201 and 204

These three status codes describe different kinds of successful requests.

Use 200 OK when the request succeeds and you have a response to return. Fetching a user or returning an updated record are common examples.

Use 201 Created when the request creates a resource. For example, after creating a user, you can return the resource and its location:

app.post('/users', async (req, res) => {
  const user = await db.createUser(req.body);

  res
    .status(201)
    .location(`/users/${user.id}`)
    .json(user);
});
Enter fullscreen mode Exit fullscreen mode

The Location header identifies the new resource. For an API that creates a user, this can make the resource easier for clients to locate.

Use 204 No Content when the request succeeds and you don't need to return a response body. A successful DELETE request is a common example:

app.delete('/users/:id', async (req, res) => {
  const deleted = await db.deleteUser(req.params.id);

  if (!deleted) {
    return res.status(404).json({
      error: 'User not found'
    });
  }

  return res.status(204).end();
});
Enter fullscreen mode Exit fullscreen mode

A 204 response must not contain a body. Don't use res.status(204).json({ deleted: true }) to communicate the result. If the client needs confirmation data in the body, use an appropriate response such as 200 OK instead.

Client errors: 400, 401, 403 and 404

These status codes are easy to mix up, but each communicates a different problem.

400 Bad Request is appropriate when the request is invalid or malformed. Examples include invalid JSON or a request that fails your input-validation rules.

401 Unauthorized means valid authentication credentials are missing or unavailable. Despite the name, it concerns authentication rather than permission. A 401 response generally needs a WWW-Authenticate header describing the authentication scheme.

For example, a Bearer-authenticated API might respond like this:

return res
  .status(401)
  .set('WWW-Authenticate', 'Bearer')
  .json({ error: 'Authentication required' });
Enter fullscreen mode Exit fullscreen mode

403 Forbidden means the server understood the request but refuses to fulfil it. A user who is authenticated but lacks permission to delete an order is a typical example.

Returning 401 to an authenticated user who lacks a required role is misleading. Asking that user to sign in again won't grant the missing permission.

404 Not Found means the requested resource couldn't be found. If /users/9999 refers to a user that doesn't exist, 404 is appropriate.

There is one useful distinction: a request for /users?role=admin can return 200 OK with an empty array. The collection endpoint exists, even if no users match the filter.

Sometimes an API may deliberately return 404 instead of 403 to avoid revealing whether a protected resource exists. That should be a consistent security decision, not an accidental choice.

Use 500 for unexpected server failures

500 Internal Server Error is for a server-side failure that prevents the request from being fulfilled. Examples include an unexpected application exception or an internal failure that hasn't been mapped to a more specific response.

A common mistake is to catch every error and return 500, including validation failures. If the client submits an invalid email address, returning 500 incorrectly suggests that the server is at fault.

Express supports centralized error handling, so routes don't have to build every error response independently.

For example, you can define an error type that carries an HTTP status:

class HttpError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
  }
}
Enter fullscreen mode Exit fullscreen mode

A route can then pass a known error to the error handler:

app.get('/orders/:id', async (req, res, next) => {
  try {
    const order = await db.findOrder(req.params.id);

    if (!order) {
      throw new HttpError(404, 'Order not found');
    }

    return res.json(order);
  } catch (err) {
    return next(err);
  }
});
Enter fullscreen mode Exit fullscreen mode

The try/catch pattern above forwards errors to Express through next(err), including when using Express 4.

You can handle those errors centrally:

app.use((err, req, res, next) => {
  if (res.headersSent) {
    return next(err);
  }

  const status = Number.isInteger(err.status) &&
    err.status >= 400 &&
    err.status <= 599
      ? err.status
      : 500;

  return res.status(status).json({
    error: status === 500
      ? 'Internal server error'
      : err.message
  });
});
Enter fullscreen mode Exit fullscreen mode

Register the error-handling middleware after your routes. The res.headersSent check matters because a response that has already started cannot simply be replaced with a new JSON error response.

For unexpected 500 errors, return a generic message rather than exposing stack traces or internal details to clients. Keep the full error in your server-side logs so you can investigate it.

Express 5 can forward rejected promises from async route handlers automatically. With Express 4, use next(err) or an appropriate async wrapper to forward asynchronous errors.

A few edge cases worth getting right

Validation errors and conflicts aren't the same thing. A malformed email can produce 400 Bad Request. A valid email that is already registered may produce 409 Conflict, because the request conflicts with the current state of the resource.

Malformed JSON can already have a client-error status. Express's JSON parser can produce a parsing error with a 400 status. A catch-all handler that overwrites every error with 500 loses that distinction.

A status code doesn't replace a useful response body. A client may need to know whether a user was missing, a permission was denied or a field was invalid. Return a concise, consistent error object alongside the appropriate status code.

For example:

{
  "error": "Validation failed",
  "details": {
    "email": "A valid email address is required"
  }
}
Enter fullscreen mode Exit fullscreen mode

Choose an error format that fits your API and document it so clients know what to expect.

Make the HTTP response tell the truth

A response body can explain what happened, but the status code tells clients whether the HTTP request succeeded, failed or needs special handling.

Returning 200 OK for every outcome forces clients to understand your custom conventions before they can interpret a response correctly. Use status codes deliberately, keep error bodies consistent, and let your API communicate clearly at both levels.

Top comments (2)

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

response.ok staying true while the body says failed is the quiet version of this bug. Proxies and monitors read the status, not your success field.

Collapse
 
loknath_patel_aafc07267d5 profile image
Loknath Patel •

Exactly! That's the key issue I wanted to point out. The HTTP status and response body should communicate a consistent outcome so clients and monitoring tools can interpret the response correctly.