Episode 3 — NodeJS MongoDB Backend Architecture / 3.17 — Error Handling in Express

3.17.a — Error Handling Basics

Express has a built-in error propagation system powered by the next() function. Understanding how synchronous and asynchronous errors flow through the middleware chain is the foundation of every robust Express application.


<< README | Next: 3.17.b — Centralized Error Handling >>


1. Why Proper Error Handling Matters

Without proper error handling, your Express application is vulnerable to three categories of failure:

WHAT HAPPENS WITHOUT ERROR HANDLING

  1. CRASHED SERVERS
     Unhandled error → Node.js process exits → ALL users lose service
     One bad request can take down the entire application

  2. POOR USER EXPERIENCE
     User sees: "Cannot GET /api/users" or a blank page
     No useful message, no status code, no guidance

  3. SECURITY LEAKS
     Stack traces exposed to the client:
     {
       "error": "TypeError: Cannot read properties of undefined (reading 'name')",
       "stack": "at /home/deploy/app/controllers/userController.js:47:22\n..."
     }
     ↑ Reveals file paths, line numbers, internal structure to attackers

The Cost of Poor Error Handling

ScenarioWithout Error HandlingWith Error Handling
Invalid user ID in URLServer crashes or sends 500 with stack traceReturns 400: "Invalid ID format"
Database connection lostUnhandled promise rejection, process may exitReturns 503: "Service unavailable", logs error, retries
Duplicate email signupCryptic MongoDB error exposed to userReturns 409: "Email already registered"
Expired JWT tokenRaw JsonWebTokenError in responseReturns 401: "Token expired, please log in again"
Missing required fieldMongoose validation dumpReturns 400: "Name is required, email is required"

2. The next() Function: Passing Control and Errors

Express middleware uses next() to pass control to the next middleware in the chain. But next() has a dual purpose: it can also pass errors.

// Normal flow: next() passes control to the next middleware
app.use((req, res, next) => {
  console.log('Middleware 1');
  next(); // → goes to the next middleware
});

app.use((req, res, next) => {
  console.log('Middleware 2');
  next(); // → goes to the route handler
});

app.get('/api/users', (req, res) => {
  res.json({ users: [] });
});

next(err) — Triggers Error-Handling Middleware

When you call next() with an argument, Express treats it as an error. It skips all remaining regular middleware and routes and jumps directly to the first error-handling middleware.

app.get('/api/users/:id', (req, res, next) => {
  const { id } = req.params;

  if (!id.match(/^[0-9a-fA-F]{24}$/)) {
    // Pass an error to Express
    const error = new Error('Invalid user ID format');
    error.statusCode = 400;
    return next(error); // ← skips everything, goes to error handler
  }

  // ... normal logic
});

// This middleware is SKIPPED when next(error) is called above
app.use(someOtherMiddleware);

// Error-handling middleware catches the error
app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  res.status(statusCode).json({ message: err.message });
});
NORMAL FLOW (no error):
  Request → Middleware 1 → Middleware 2 → Route Handler → Response

ERROR FLOW (next(err) called):
  Request → Middleware 1 → next(err) ──SKIP──SKIP──> Error Handler → Response

3. Synchronous Errors: Express Catches throw Automatically

In synchronous route handlers and middleware, Express automatically catches thrown errors and forwards them to the error-handling middleware. You do not need to call next(err) explicitly.

// Express catches this throw automatically
app.get('/api/sync-error', (req, res) => {
  throw new Error('Something went wrong!');
  // Express catches this and calls next(err) internally
});

// This also works in middleware
app.use((req, res, next) => {
  if (!req.headers.authorization) {
    throw new Error('No authorization header'); // Express catches this
  }
  next();
});

This works because Express wraps synchronous handlers in a try-catch internally. However, this does NOT work for asynchronous code.


4. Async Errors: Express 4.x Does NOT Catch Promise Rejections

This is the most common source of unhandled errors in Express applications. In Express 4.x (the version most projects use today), thrown errors inside async functions are NOT caught by Express.

// DANGER: Express 4.x does NOT catch this error!
app.get('/api/users', async (req, res) => {
  const users = await User.find(); // If this throws, Express won't catch it
  res.json(users);
});

// What happens:
// 1. User.find() rejects (e.g., database is down)
// 2. The async function returns a rejected Promise
// 3. Express 4.x does NOT handle rejected Promises
// 4. Node.js emits an 'unhandledRejection' warning
// 5. The request HANGS — no response is ever sent
// 6. Eventually the client times out

Why This Happens

Express 4.x was built before async/await was common. Its internal try-catch only works for synchronous code. An async function returns a Promise, and Express 4.x does not attach a .catch() to that Promise.

// Express 4.x internally does something like:
try {
  routeHandler(req, res, next);  // Returns a Promise, but Express ignores it
} catch (err) {
  next(err);  // Only catches synchronous throws
}
// The returned Promise's rejection goes unhandled

The Manual Fix: try...catch

// SAFE: Manually catching async errors
app.get('/api/users', async (req, res, next) => {
  try {
    const users = await User.find();
    res.json(users);
  } catch (err) {
    next(err); // Manually forward to error handler
  }
});

5. Express 5.x: Auto-Catches Async Errors

Express 5.x (currently in beta) automatically catches rejected Promises returned by route handlers and middleware. This eliminates the need for manual try-catch or asyncHandler wrappers.

// Express 5.x — this works without try-catch!
app.get('/api/users', async (req, res) => {
  const users = await User.find(); // If this rejects, Express 5 catches it
  res.json(users);
});
// Express 5 automatically calls next(err) if the Promise rejects

Until Express 5.x is stable and widely adopted, you need the patterns covered in this section and in 3.17.c (asyncHandler).


6. Error Types: Operational vs Programmer Errors

Not all errors are created equal. Understanding the distinction between operational and programmer errors is critical for deciding how to handle them.

Operational Errors (Expected)

These are errors you anticipate in normal operation. They happen because of external conditions, not because your code is broken.

ErrorStatus CodeExample
Resource not found404User with given ID does not exist
Validation failure400Missing required field, invalid email format
Authentication failure401Invalid or expired token
Authorization failure403User does not have permission
Duplicate resource409Email already registered
Rate limit exceeded429Too many requests
Service unavailable503Database or external API temporarily down

Programmer Errors (Bugs)

These are bugs in your code. They should never happen in a correctly written application.

ErrorCause
TypeError: Cannot read properties of undefinedAccessing property on undefined variable
ReferenceError: x is not definedUsing an undeclared variable
SyntaxErrorMalformed JSON, typo in code
Calling a function with wrong argumentsLogic error in code
Reading from a closed database connectionResource management bug

How to Handle Each

// OPERATIONAL: Expected, recoverable — send appropriate HTTP response
if (!user) {
  return next(new ApiError(404, 'User not found')); // Clean response to client
}

// PROGRAMMER: Unexpected, bug — log it, alert developers, potentially restart
// These usually result in a 500 Internal Server Error
// In production, you may want to gracefully restart the process

7. try...catch in Route Handlers

The most straightforward way to handle async errors in Express 4.x is wrapping every route handler in a try-catch block.

// Pattern: try-catch in every async route handler
app.get('/api/users', async (req, res, next) => {
  try {
    const users = await User.find().select('-password');
    res.json({ success: true, count: users.length, data: users });
  } catch (err) {
    next(err);
  }
});

app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) {
      return next(new Error('User not found'));
    }
    res.json({ success: true, data: user });
  } catch (err) {
    next(err);
  }
});

app.post('/api/users', async (req, res, next) => {
  try {
    const user = await User.create(req.body);
    res.status(201).json({ success: true, data: user });
  } catch (err) {
    next(err);
  }
});

The Problem: Repetitive and Error-Prone

Every single async route handler needs the same try-catch wrapper. In a real application with dozens or hundreds of routes, this creates massive boilerplate. It is also easy to forget the try-catch in one handler, creating a silent failure. Section 3.17.c introduces asyncHandler to solve this problem.


8. Unhandled Promise Rejections: process.on('unhandledRejection')

Even with careful try-catch usage, some promise rejections may slip through. Node.js emits an unhandledRejection event for any Promise that rejects without a .catch() handler.

// server.js — global safety net for unhandled promise rejections
const server = app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

process.on('unhandledRejection', (reason, promise) => {
  console.error('UNHANDLED REJECTION! Shutting down...');
  console.error('Reason:', reason);

  // Close server gracefully, then exit
  server.close(() => {
    process.exit(1); // Exit with failure code
  });
});

Why Graceful Shutdown?

BAD: process.exit(1) immediately
  → All active requests are dropped mid-response
  → Database connections are not properly closed
  → Users see connection reset errors

GOOD: server.close() then process.exit(1)
  → Stop accepting NEW connections
  → Wait for existing requests to finish
  → Close database connections cleanly
  → THEN exit the process
  → Process manager (PM2, Docker) restarts a new instance

9. Uncaught Exceptions: process.on('uncaughtException')

Uncaught exceptions are synchronous errors that are not caught by any try-catch. These put the Node.js process in an unreliable state.

// Global handler for uncaught synchronous exceptions
process.on('uncaughtException', (err) => {
  console.error('UNCAUGHT EXCEPTION! Shutting down...');
  console.error(err.name, err.message);
  console.error(err.stack);

  // MUST exit — the process is in an undefined state
  process.exit(1);
});

// This MUST be registered before any other code runs
// Place it at the very top of your entry file (server.js)

When Do Uncaught Exceptions Happen?

// Example 1: Typo outside of any handler
const x = undefined;
console.log(x.name); // TypeError — uncaught if not in try-catch

// Example 2: Error in a setTimeout callback
setTimeout(() => {
  throw new Error('This is uncaught!');
}, 1000);

// Example 3: Error in an event emitter
const emitter = new EventEmitter();
emitter.emit('error', new Error('Unhandled emitter error'));

Important: Never Continue After uncaughtException

After an uncaught exception, the process is in an unpredictable state. Memory may be corrupted, file handles may be leaked, and database connections may be in a bad state. Always exit the process. Use a process manager like PM2 or Docker to automatically restart.


10. Putting the Basics Together

Here is a minimal Express application demonstrating all the basic error handling concepts:

// server.js
const express = require('express');
const mongoose = require('mongoose');
const app = express();

// -- Must be at the very top --
process.on('uncaughtException', (err) => {
  console.error('UNCAUGHT EXCEPTION! Shutting down...');
  console.error(err.name, err.message);
  process.exit(1);
});

app.use(express.json());

// -- Sync route: Express catches thrown errors automatically --
app.get('/api/sync-error', (req, res) => {
  throw new Error('Synchronous error demo');
});

// -- Async route: Must use try-catch in Express 4.x --
app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await mongoose.model('User').findById(req.params.id);
    if (!user) {
      const error = new Error('User not found');
      error.statusCode = 404;
      return next(error);
    }
    res.json(user);
  } catch (err) {
    next(err);
  }
});

// -- 404 handler for undefined routes --
app.all('*', (req, res, next) => {
  const error = new Error(`Cannot find ${req.originalUrl} on this server`);
  error.statusCode = 404;
  next(error);
});

// -- Error-handling middleware (MUST have 4 parameters) --
app.use((err, req, res, next) => {
  err.statusCode = err.statusCode || 500;
  err.message = err.message || 'Internal Server Error';

  res.status(err.statusCode).json({
    success: false,
    message: err.message,
  });
});

// -- Start server --
const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

// -- Unhandled promise rejections --
process.on('unhandledRejection', (reason) => {
  console.error('UNHANDLED REJECTION! Shutting down...');
  console.error(reason);
  server.close(() => process.exit(1));
});

Key Takeaways

  1. next(err) skips all remaining middleware and routes, jumping directly to the error-handling middleware
  2. Express automatically catches thrown errors in synchronous handlers but NOT in async handlers (Express 4.x)
  3. Every async route handler in Express 4.x needs a try...catch that calls next(err) on failure
  4. Operational errors (404, validation, auth) are expected and should return appropriate HTTP responses
  5. Programmer errors (TypeError, ReferenceError) are bugs and typically result in 500 responses
  6. process.on('unhandledRejection') is your safety net for missed promise rejections -- shut down gracefully
  7. process.on('uncaughtException') catches synchronous errors outside handlers -- always exit the process afterward
  8. Express 5.x will automatically handle async errors, but until then, manual patterns are required

Explain-It Challenge

Scenario: A junior developer on your team wrote this Express route:

app.get('/api/products/:id', async (req, res) => {
  const product = await Product.findById(req.params.id);
  if (!product) {
    res.status(404).json({ message: 'Not found' });
  }
  res.json(product);
});

This route has three error handling problems. Identify all three, explain what could go wrong in each case, and rewrite the route to handle all errors correctly. Consider: What happens if the database is down? What if the ID format is invalid? What if the product is not found?


<< README | Next: 3.17.b — Centralized Error Handling >>