Episode 3 — NodeJS MongoDB Backend Architecture / 3.9 — REST API Development

3.9.d — Status Codes in Practice

In one sentence: Choosing the right HTTP status code is not academic trivia — it tells clients exactly what happened, enables proper error handling, and makes your API predictable; this guide covers every status code you will use in production Express APIs.

Navigation: <- 3.9.c — Postman for API Testing | 3.9.e — Input Validation and Sanitization ->


1. The Big Picture

FamilyMeaningWho is responsible?
1xxInformationalServer (rare in REST APIs)
2xxSuccessEverything worked
3xxRedirectionClient needs to go elsewhere
4xxClient ErrorClient sent a bad request
5xxServer ErrorServer failed to handle a valid request

Rule of thumb: If the client could fix the problem by changing the request, it is a 4xx. If the server is at fault, it is a 5xx.


2. 2xx — Success Patterns

200 OK — General success

The default "everything worked" code. Use for successful GET requests and for updates that return the modified resource.

// GET /api/users — list all users
app.get('/api/users', async (req, res) => {
  const users = await User.find();
  res.status(200).json({
    data: users,
    count: users.length
  });
});

// PATCH /api/users/:id — update and return the user
app.patch('/api/users/:id', async (req, res) => {
  const user = await User.findByIdAndUpdate(req.params.id, req.body, { new: true });
  if (!user) return res.status(404).json({ error: { message: 'User not found' } });
  res.status(200).json({ data: user });
});

201 Created — Resource created successfully

Use after a successful POST that creates a new resource. Best practice: include a Location header pointing to the new resource.

// POST /api/users — create a new user
app.post('/api/users', async (req, res) => {
  const user = await User.create(req.body);
  res
    .status(201)
    .location(`/api/users/${user._id}`)
    .json({ data: user });
});

// Response headers will include:
// Location: /api/users/64abc123def456

204 No Content — Success with no response body

Use for successful DELETE operations or updates where the client does not need the response body.

// DELETE /api/users/:id
app.delete('/api/users/:id', async (req, res) => {
  const user = await User.findByIdAndDelete(req.params.id);
  if (!user) return res.status(404).json({ error: { message: 'User not found' } });
  res.status(204).send(); // no body — use .send() not .json()
});

Important: Do not send a body with 204. Use .send() instead of .json().

Quick reference: which 2xx to use

OperationStatusBody?
GET success200Yes (the resource)
POST creates resource201Yes (the created resource) + Location header
PUT replaces resource200Yes (the replaced resource)
PATCH updates resource200Yes (the updated resource)
DELETE removes resource204No
Action endpoint (e.g., send email)200 or 202Yes (confirmation or job status)

3. 4xx — Client Error Patterns

400 Bad Request — Malformed or invalid request

The catch-all for "your request is wrong." Use for: malformed JSON, missing required fields, invalid data types.

// Malformed JSON — Express handles this automatically
app.use(express.json());
// If client sends invalid JSON, Express returns 400 by default

// Manual validation
app.post('/api/users', (req, res) => {
  const { name, email } = req.body;
  if (!name || !email) {
    return res.status(400).json({
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Missing required fields',
        details: [
          ...(!name ? [{ field: 'name', message: 'Name is required' }] : []),
          ...(!email ? [{ field: 'email', message: 'Email is required' }] : [])
        ]
      }
    });
  }
  // ... create user
});

401 Unauthorized — Authentication failed or missing

The client is not authenticated. They either did not send credentials or the credentials are invalid.

// Auth middleware
const authenticate = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];

  if (!token) {
    return res.status(401).json({
      error: {
        code: 'AUTH_REQUIRED',
        message: 'Authentication required. Please provide a valid Bearer token.'
      }
    });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(401).json({
      error: {
        code: 'INVALID_TOKEN',
        message: 'Token is invalid or expired'
      }
    });
  }
};

403 Forbidden — Authenticated but not authorized

The server knows who you are but you do not have permission to perform this action.

// Authorization middleware
const requireRole = (...roles) => {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        error: {
          code: 'FORBIDDEN',
          message: `This action requires one of these roles: ${roles.join(', ')}`
        }
      });
    }
    next();
  };
};

// Usage
app.delete('/api/users/:id', authenticate, requireRole('admin'), async (req, res) => {
  await User.findByIdAndDelete(req.params.id);
  res.status(204).send();
});

Key distinction:

  • 401 = "Who are you?" (identity problem)
  • 403 = "I know who you are, but you can't do this" (permission problem)

404 Not Found — Resource does not exist

app.get('/api/users/:id', async (req, res) => {
  // Validate ObjectId format first to avoid Mongoose CastError
  if (!mongoose.Types.ObjectId.isValid(req.params.id)) {
    return res.status(400).json({
      error: { code: 'INVALID_ID', message: 'Invalid user ID format' }
    });
  }

  const user = await User.findById(req.params.id);
  if (!user) {
    return res.status(404).json({
      error: { code: 'NOT_FOUND', message: `User with ID ${req.params.id} not found` }
    });
  }

  res.json({ data: user });
});

409 Conflict — Duplicate or version conflict

app.post('/api/users', async (req, res) => {
  const existing = await User.findOne({ email: req.body.email });
  if (existing) {
    return res.status(409).json({
      error: {
        code: 'DUPLICATE_RESOURCE',
        message: 'A user with this email already exists',
        details: { field: 'email', value: req.body.email }
      }
    });
  }
  // ... create user
});

422 Unprocessable Entity — Semantic validation failure

The JSON is well-formed (not a 400), but the data fails business rules.

app.post('/api/orders', async (req, res) => {
  const { items, shippingAddress } = req.body;

  // Syntax is fine, but business logic fails
  const outOfStock = items.filter(item => item.quantity > item.available);
  if (outOfStock.length > 0) {
    return res.status(422).json({
      error: {
        code: 'UNPROCESSABLE_ENTITY',
        message: 'Some items are out of stock',
        details: outOfStock.map(item => ({
          productId: item.productId,
          requested: item.quantity,
          available: item.available
        }))
      }
    });
  }
  // ... process order
});

429 Too Many Requests — Rate limited

const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,
  standardHeaders: true,   // Return rate limit info in RateLimit-* headers
  legacyHeaders: false,
  message: {
    error: {
      code: 'RATE_LIMITED',
      message: 'Too many requests. Please try again later.',
      retryAfter: 900  // seconds
    }
  }
});

app.use('/api/', limiter);

Response headers from express-rate-limit:

RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1714567890
Retry-After: 900

4. 5xx — Server Error Patterns

500 Internal Server Error — Unhandled exception

// Global error handler — catches all unhandled errors
app.use((err, req, res, next) => {
  console.error('Unhandled error:', err.stack);

  // Never expose internal error details in production
  const isDev = process.env.NODE_ENV === 'development';

  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: isDev ? err.message : 'Something went wrong. Please try again later.',
      ...(isDev && { stack: err.stack })
    }
  });
});

502 Bad Gateway — Upstream service is down

When your API depends on another service that fails:

app.get('/api/weather', async (req, res) => {
  try {
    const response = await fetch('https://weather-api.example.com/current');
    if (!response.ok) throw new Error('Weather API returned error');
    const data = await response.json();
    res.json({ data });
  } catch (err) {
    res.status(502).json({
      error: {
        code: 'BAD_GATEWAY',
        message: 'Weather service is currently unavailable'
      }
    });
  }
});

503 Service Unavailable — Maintenance or overloaded

// Maintenance mode middleware
const maintenanceMode = (req, res, next) => {
  if (process.env.MAINTENANCE_MODE === 'true') {
    return res.status(503)
      .set('Retry-After', '3600')  // try again in 1 hour
      .json({
        error: {
          code: 'SERVICE_UNAVAILABLE',
          message: 'API is under maintenance. Please try again later.',
          retryAfter: 3600
        }
      });
  }
  next();
};

app.use(maintenanceMode);

5. Status Code Decision Flowchart

Request arrives
  |
  +--> Is the request well-formed? (valid JSON, correct Content-Type)
  |     NO --> 400 Bad Request
  |
  +--> Is the client authenticated?
  |     NO --> 401 Unauthorized
  |
  +--> Is the client authorized for this action?
  |     NO --> 403 Forbidden
  |
  +--> Does the requested resource exist?
  |     NO --> 404 Not Found
  |
  +--> Is the client rate-limited?
  |     YES --> 429 Too Many Requests
  |
  +--> Does the request pass validation?
  |     NO --> 422 Unprocessable Entity (or 400)
  |
  +--> Does the request conflict with existing data?
  |     YES --> 409 Conflict
  |
  +--> Can the server process this successfully?
  |     NO --> 500 Internal Server Error
  |     (or 502 if upstream, 503 if overloaded)
  |
  +--> SUCCESS:
        GET    --> 200 OK
        POST   --> 201 Created
        PUT    --> 200 OK
        PATCH  --> 200 OK
        DELETE --> 204 No Content

6. Consistent Error Response Format

Every error response in your API should follow one consistent shape. This makes client-side error handling predictable.

Standard error envelope

// Consistent error shape across your entire API
{
  "error": {
    "code": "VALIDATION_ERROR",      // machine-readable error code
    "message": "Email is required",   // human-readable message
    "details": [                      // optional: field-level errors
      {
        "field": "email",
        "message": "Email is required",
        "type": "required"
      }
    ]
  }
}

Error response utility

// src/utils/apiError.js
class ApiError extends Error {
  constructor(statusCode, code, message, details = null) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.details = details;
  }

  static badRequest(message, details) {
    return new ApiError(400, 'BAD_REQUEST', message, details);
  }

  static unauthorized(message = 'Authentication required') {
    return new ApiError(401, 'UNAUTHORIZED', message);
  }

  static forbidden(message = 'You do not have permission') {
    return new ApiError(403, 'FORBIDDEN', message);
  }

  static notFound(resource = 'Resource') {
    return new ApiError(404, 'NOT_FOUND', `${resource} not found`);
  }

  static conflict(message, details) {
    return new ApiError(409, 'CONFLICT', message, details);
  }

  static tooMany(retryAfter = 900) {
    return new ApiError(429, 'RATE_LIMITED', 'Too many requests', { retryAfter });
  }

  static internal(message = 'Internal server error') {
    return new ApiError(500, 'INTERNAL_ERROR', message);
  }
}

module.exports = ApiError;

Global error handler using ApiError

// src/middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
  // Handle our custom ApiError
  if (err.statusCode) {
    return res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
        ...(err.details && { details: err.details })
      }
    });
  }

  // Handle Mongoose validation errors
  if (err.name === 'ValidationError') {
    const details = Object.values(err.errors).map(e => ({
      field: e.path,
      message: e.message,
      type: e.kind
    }));
    return res.status(400).json({
      error: { code: 'VALIDATION_ERROR', message: 'Validation failed', details }
    });
  }

  // Handle Mongoose duplicate key
  if (err.code === 11000) {
    const field = Object.keys(err.keyValue)[0];
    return res.status(409).json({
      error: {
        code: 'DUPLICATE_KEY',
        message: `Duplicate value for ${field}`,
        details: { field, value: err.keyValue[field] }
      }
    });
  }

  // Fallback for unknown errors
  console.error('Unhandled error:', err);
  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong'
    }
  });
};

module.exports = errorHandler;

Using ApiError in routes

const ApiError = require('../utils/apiError');

app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) throw ApiError.notFound('User');
    res.json({ data: user });
  } catch (err) {
    next(err);  // pass to global error handler
  }
});

7. Key Takeaways

  1. Pick the most specific status code — 409 for duplicates, 422 for business-rule failures, not just 400 for everything.
  2. 401 vs 403: 401 is "who are you?" (authentication), 403 is "you can't do this" (authorization).
  3. 201 + Location header for created resources; 204 with no body for deletes.
  4. Never expose stack traces in production — use a global error handler that sanitizes 5xx errors.
  5. Build a consistent error envelope ({ error: { code, message, details } }) and use it everywhere.

Explain-It Challenge

Explain without notes:

  1. Walk through the status code decision flowchart for a POST request that tries to create a user with a duplicate email.
  2. Why should you return 422 instead of 400 when the JSON is valid but the data violates a business rule?
  3. How does an ApiError utility class improve consistency across a large Express application?

Navigation: <- 3.9.c — Postman for API Testing | 3.9.e — Input Validation and Sanitization ->