Avancé25 min de lecture

Error Handling & Status Codes

Build robust error handling with try/catch, custom error classes, and a centralized error middleware in Express.

Why Error Handling Matters

Every server-side application will encounter errors. A database connection might fail, a user might send malformed data, an external API might time out, or a file might not exist. What separates a professional backend application from an amateur one is how it handles these inevitable failures.

Unhandled errors crash your server. In Node.js, an uncaught exception will terminate the entire process. If your Express server throws an error that nobody catches, every connected user loses their session. Your API goes down. Your business loses money. This is not hypothetical — it is the number one cause of production outages in Node.js applications.

Bad error handling leaks internal details to attackers. If you send a raw error object back to the client, it might contain your database connection string, your file system paths, your library versions, or your SQL queries. Attackers use this information to find vulnerabilities. The 2017 Equifax breach that exposed 147 million records started with an error message that revealed too much.

Good error handling follows these principles:

  1. Catch all errors — No error should go unhandled. Every async operation, every database call, every external request should have error handling.
  2. Send appropriate HTTP status codes — A missing resource is 404, not 500. Bad input is 400, not 200. Status codes tell the client exactly what went wrong.
  3. Return consistent error responses — Every error response should have the same shape: { error: { message, statusCode } }. Clients should not have to guess the format.
  4. Log errors for debugging — Errors should be logged with timestamps, request IDs, stack traces, and context. You need this information to diagnose problems at 3 AM.
  5. Never expose stack traces in production — Stack traces are for developers, not end users. In production, send a clean, human-readable message. In development, include the full stack trace for debugging.

try/catch in Async Route Handlers

Express was designed before async/await existed. Its error handling mechanism relies on the next(error) callback — when you pass an error to next(), Express skips to the error-handling middleware. This works perfectly with synchronous code and traditional callbacks, but it has a critical limitation: Express does not catch errors thrown inside async functions.

Consider this route handler:

javascript
app.get('/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id); // This might throw!
  res.json(user);
});

If User.findById() throws an error (invalid ID format, database connection lost), the promise rejects. Express does not catch this rejection. The request hangs forever — the client never gets a response, the error is swallowed silently, and your server leaks memory.

The fix: wrap every async handler in try/catch.

javascript
app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    res.json(user);
  } catch (error) {
    next(error); // Pass the error to Express error middleware
  }
});

But writing try/catch in every single route handler is tedious and error-prone. If you forget it in even one handler, you have a potential crash. The solution is the asyncHandler wrapper pattern — a higher-order function that wraps your async handler, catches any rejected promise, and forwards it to next() automatically:

javascript
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

Now you can write clean handlers without manual try/catch:

javascript
app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  res.json(user);
}));

The asyncHandler catches the rejection and calls next(error) for you. This is such a common pattern that many Express projects include it as a utility, and packages like express-async-errors patch Express to do it automatically.

Custom Error Classes

JavaScript's built-in Error class is too generic for a well-structured API. When an error reaches your error-handling middleware, you need to know: What HTTP status code should I send? Is this an error the user caused (bad input) or a bug in my code? Should I log this as a warning or a critical alert?

The solution is custom error classes that extend the built-in Error and add properties specific to your application.

The base class is AppError:

javascript
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true; // Distinguishes expected errors from bugs
    Error.captureStackTrace(this, this.constructor);
  }
}

The isOperational property is crucial. It separates two fundamentally different types of errors:

Operational errors are expected, recoverable situations: a user submits invalid data (400), tries to access a resource that does not exist (404), sends an expired authentication token (401), or lacks permission (403). These are not bugs — they are normal parts of API operation. You handle them gracefully and send a clear message to the client.

Programming errors are bugs in your code: reading a property of undefined, calling a function that does not exist, running out of memory, failing to handle a null value. These are unexpected and indicate something is wrong with the application itself. You log them aggressively, alert the development team, and send a generic "Internal Server Error" to the client.

From AppError, you create specific subclasses for common HTTP error scenarios:

  • ValidationError (400) — The request body has invalid or missing fields
  • AuthenticationError (401) — The user is not logged in or their token is invalid
  • ForbiddenError (403) — The user is logged in but lacks permission for this action
  • NotFoundError (404) — The requested resource does not exist in the database

Each subclass sets its own status code and default message, so throwing an error is clean and expressive: throw new NotFoundError('User not found') instead of throw new Error('User not found') with a separate status code variable.

Custom Error Classes & asyncHandler

javascript
// ── Base Error Class ──────────────────────────────────
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
    // Captures where the error was thrown for clean stack traces
    Error.captureStackTrace(this, this.constructor);
  }
}

// ── Specific Error Types ─────────────────────────────
class ValidationError extends AppError {
  constructor(message = 'Invalid input data') {
    super(message, 400);
    this.name = 'ValidationError';
  }
}

class AuthenticationError extends AppError {
  constructor(message = 'Not authenticated') {
    super(message, 401);
    this.name = 'AuthenticationError';
  }
}

class ForbiddenError extends AppError {
  constructor(message = 'Access denied') {
    super(message, 403);
    this.name = 'ForbiddenError';
  }
}

class NotFoundError extends AppError {
  constructor(message = 'Resource not found') {
    super(message, 404);
    this.name = 'NotFoundError';
  }
}

// ── asyncHandler Wrapper ─────────────────────────────
// Wraps async route handlers to catch rejected promises
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

// ── Usage in Routes ──────────────────────────────────
app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    throw new NotFoundError(`User ${req.params.id} not found`);
  }
  res.json(user);
}));

app.post('/users', asyncHandler(async (req, res) => {
  const { name, email } = req.body;
  if (!name || !email) {
    throw new ValidationError('Name and email are required');
  }
  const user = await User.create({ name, email });
  res.status(201).json(user);
}));

Centralized Error Middleware

Instead of handling errors in every individual route, Express lets you define a centralized error-handling middleware that processes all errors in one place. This middleware has a special signature — it takes four parameters: (err, req, res, next). The extra first parameter (err) is what tells Express this is an error handler, not a regular middleware.

Critical rule: the error middleware must be the LAST middleware registered, after all routes and other middleware. Express processes middleware in order, and errors bubble down to the first error-handling middleware they find.

javascript
// All routes and regular middleware go first
app.use('/api/users', userRoutes);
app.use('/api/posts', postRoutes);

// Error middleware goes LAST
app.use((err, req, res, next) => { ... });

Inside the error middleware, you implement your error-handling strategy:

  1. Determine the status code — If the error is an AppError, use its statusCode. Otherwise, default to 500.
  2. Format the response — Create a consistent error response object with message, statusCode, and optionally stack.
  3. Distinguish development from production — In development, include the full stack trace and error details for debugging. In production, send only a clean user-facing message. Never expose internal details.
  4. Log the error — Use structured logging (not just console.error). Include the request method, URL, timestamp, user ID if authenticated, and the full error with stack trace. This is your lifeline when debugging production issues.
  5. Handle specific error types — Mongoose validation errors, JWT expired errors, duplicate key errors from MongoDB — each has a different structure. Transform them into your consistent AppError format.

The beauty of centralized error handling is that your route handlers stay clean. They just throw errors and let the middleware deal with formatting, logging, and responding. If you need to change how errors are formatted (say, adding an error tracking service like Sentry), you change it in one place.

Centralized Error Middleware

javascript
// ── Centralized Error Handler ─────────────────────────
const errorHandler = (err, req, res, next) => {
  // Default to 500 if no status code is set
  err.statusCode = err.statusCode || 500;
  err.message = err.message || 'Internal Server Error';

  // Log the error (in real apps, use a proper logger like Winston)
  console.error(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
  console.error(`Status: ${err.statusCode} | Message: ${err.message}`);
  if (err.stack) console.error(err.stack);

  // Development: send full error details
  if (process.env.NODE_ENV === 'development') {
    return res.status(err.statusCode).json({
      status: 'error',
      statusCode: err.statusCode,
      message: err.message,
      stack: err.stack,        // Full stack trace for debugging
      error: err               // The complete error object
    });
  }

  // Production: send clean response, no internals
  if (err.isOperational) {
    // Operational errors: safe to send the message to the client
    return res.status(err.statusCode).json({
      status: 'error',
      statusCode: err.statusCode,
      message: err.message
    });
  }

  // Programming errors: don't leak details
  return res.status(500).json({
    status: 'error',
    statusCode: 500,
    message: 'Something went wrong'
  });
};

// ── Handle Unknown Routes ────────────────────────────
// Catch requests to routes that don't exist
app.all('*', (req, res, next) => {
  next(new NotFoundError(`Cannot find ${req.method} ${req.originalUrl}`));
});

// ── Register Error Middleware (MUST be last) ─────────
app.use(errorHandler);

How many parameters does an Express error-handling middleware have?

Prêt à pratiquer ?

Crée ton compte gratuit pour accéder à l'éditeur de code interactif, lancer les défis et suivre ta progression.