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

3.17 — Error Handling in Express: Quick Revision

Compact cheat sheet. Print-friendly.


<< Previous: Interview Questions | Next Section: 3.18 — Testing Tools >>


How to Use This Material

  • Use this as a rapid review before interviews, exams, or starting a new Express project
  • Read through the full sub-topic files (3.17.a, 3.17.b, 3.17.c) first for deep understanding
  • After reviewing, test yourself with the Exercise Questions
  • Revisit the Interview Questions for model answers to common interview scenarios
  • Print this page or keep it open as a reference when building your error handling layer

Core Vocabulary

TermOne-liner
Operational errorAn expected, recoverable error caused by external conditions -- not a bug (e.g., 404 not found, 400 validation failure, 401 expired token)
Programmer errorA bug in your code that should never happen in correct software (e.g., TypeError, ReferenceError, null dereference)
Error middlewareExpress middleware with exactly 4 parameters (err, req, res, next) that catches all errors forwarded via next(err)
ApiErrorA custom class extending Error that adds statusCode, isOperational, and status for consistent HTTP error responses
asyncHandlerA higher-order function that wraps async route handlers, catching rejected Promises and forwarding them to next(err) automatically
next(err)Calling Express's next() with an argument skips all remaining regular middleware and routes, jumping directly to the first error-handling middleware

Error Middleware Signature

// MUST have exactly 4 parameters — Express checks fn.length === 4
app.use((err, req, res, next) => {
  err.statusCode = err.statusCode || 500;
  res.status(err.statusCode).json({
    success: false,
    message: err.message,
  });
});
REGULAR MIDDLEWARE             ERROR-HANDLING MIDDLEWARE
  (req, res, next)               (err, req, res, next)
  3 parameters                   4 parameters — ALL FOUR required!

Express identifies error middleware by parameter count.
If you write only 3 params, Express treats it as regular middleware — NEVER called for errors.
Must be registered AFTER all routes: app.use(errorHandler) as the LAST middleware.

ApiError Class

class ApiError extends Error {
  constructor(statusCode, message, isOperational = true, stack = '') {
    super(message);
    this.statusCode = statusCode;                               // 400, 401, 404, 500...
    this.isOperational = isOperational;                         // true = expected, false = bug
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; // 4xx = fail, 5xx = error
    if (stack) {
      this.stack = stack;
    } else {
      Error.captureStackTrace(this, this.constructor);          // Clean stack trace
    }
  }
}

// Usage:
throw new ApiError(400, 'Email is required');       // Validation
throw new ApiError(401, 'Invalid token');           // Auth
throw new ApiError(404, 'User not found');          // Not found
throw new ApiError(409, 'Email already registered');// Duplicate
throw new ApiError(500, 'DB connection failed', false); // Bug (isOperational = false)

asyncHandler Pattern

// The entire utility — one line
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);
// Usage in routes — no try-catch needed
router.get('/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new ApiError(404, 'User not found');
  res.json({ success: true, data: user });
}));

// Usage in middleware
const protect = asyncHandler(async (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) throw new ApiError(401, 'No token');
  const decoded = jwt.verify(token, process.env.JWT_SECRET);
  req.user = await User.findById(decoded.id);
  next();
});
How asyncHandler works:
  1. Receives your async function (fn)
  2. Returns a new (req, res, next) function
  3. Calls fn(req, res, next) → returns a Promise
  4. Promise.resolve() wraps it (handles sync fns too)
  5. .catch(next) forwards any rejection to error middleware

Alternative: require('express-async-errors') once at top of app.js
  → Monkey-patches Express globally, no wrapping needed

Error Response Format

Development (full details for debugging):

{
  "success": false,
  "status": "fail",
  "message": "Cast to ObjectId failed for value \"abc\" at path \"_id\"",
  "stack": "CastError: ...\n    at /app/controllers/userController.js:25:30\n...",
  "error": { "kind": "ObjectId", "value": "abc", "path": "_id" }
}

Production -- operational error (safe, user-facing):

{
  "success": false,
  "status": "fail",
  "message": "Invalid _id: abc"
}

Production -- programmer error (generic, no details leaked):

{
  "success": false,
  "status": "error",
  "message": "Something went wrong"
}

Error Type Conversion Table

Error SourceDetectionHTTP CodeConverted Message
Mongoose CastErrorerr.name === 'CastError'400Invalid ${err.path}: ${err.value}
Mongoose ValidationErrorerr.name === 'ValidationError'400Validation failed: ${field messages}
MongoDB duplicate keyerr.code === 11000409Duplicate value for '${field}'
JWT invalid tokenerr.name === 'JsonWebTokenError'401Invalid token. Please log in again.
JWT expired tokenerr.name === 'TokenExpiredError'401Your token has expired. Please log in again.
Express body parsererr instanceof SyntaxError && err.status === 400400Invalid JSON in request body
Customerr instanceof ApiErrorvariesCustom message from constructor

Common Patterns

PatternCode / Description
Throw from routethrow new ApiError(404, 'Not found') inside asyncHandler-wrapped route
Forward with nextreturn next(new ApiError(400, 'Bad input')) in sync middleware
404 catch-allapp.all('*', (req, res, next) => { next(new ApiError(404, \Cannot find ${req.originalUrl}`)); })`
Convert Mongoose errorsCheck err.name / err.code in centralized handler, return new ApiError(...)
Async middlewareWrap auth/validation middleware with asyncHandler just like routes
Multiple error handlersChain logErrors then sendErrorResponse, connected by next(err)
express-async-errorsrequire('express-async-errors') once -- eliminates all asyncHandler wrapping

Middleware Registration Order

1. express.json()              ← Parse request body
2. cors(), helmet(), etc.      ← Security middleware
3. Authentication middleware   ← Verify tokens
4. Route definitions           ← /api/users, /api/products, etc.
5. 404 catch-all               ← app.all('*', ...) for unmatched routes
6. Error handler               ← app.use(errorHandler) — MUST BE LAST

Error Flow Diagram

SYNC ERRORS:
  Express catches throw in sync handlers automatically → next(err)

ASYNC ERRORS (Express 4.x):
  Express does NOT catch rejected Promises
  → asyncHandler wraps fn and calls .catch(next)
  → OR use try-catch with next(err)
  → OR require('express-async-errors')

ASYNC ERRORS (Express 5.x):
  Express catches rejected Promises automatically → next(err)

FLOW:
  Route/Middleware throws or rejects
       │
       ├── asyncHandler catches → calls next(err)
       │
       ▼
  Centralized Error Handler (err, req, res, next)
       │
       ├── CastError?       → ApiError(400)
       ├── ValidationError? → ApiError(400)
       ├── Code 11000?      → ApiError(409)
       ├── JWT error?       → ApiError(401)
       ├── Already ApiError? → use as-is
       └── Unknown?         → ApiError(500)
       │
       ▼
  JSON Response { success: false, message: '...' }

asyncHandler vs express-async-errors vs Express 5

FeatureasyncHandlerexpress-async-errorsExpress 5.x
DependencyNone (your own code)npm package (~1KB)Built-in
UsageWrap each route/middlewarerequire() onceNothing extra
ExplicitnessExplicit (visible)Implicit (global patch)Implicit (native)
Express 4 compatibleYesYesN/A
Express 5 compatibleYes (unnecessary)Yes (unnecessary)Native

Production Checklist

ERROR UTILITIES
  [ ] ApiError class: statusCode, message, isOperational, status, stack
  [ ] asyncHandler utility created and used consistently
  [ ] Specialized subclasses if needed: NotFoundError, UnauthorizedError

ROUTE & MIDDLEWARE LEVEL
  [ ] asyncHandler wraps ALL async route handlers
  [ ] asyncHandler wraps ALL async middleware (auth, validation, etc.)
  [ ] throw new ApiError(...) for all operational errors
  [ ] No try-catch boilerplate in routes (asyncHandler handles it)

CENTRALIZED ERROR HANDLER
  [ ] Registered as the LAST middleware: app.use(errorHandler)
  [ ] Converts Mongoose CastError → 400
  [ ] Converts Mongoose ValidationError → 400 with field messages
  [ ] Converts MongoDB duplicate key (code 11000) → 409
  [ ] Converts JWT JsonWebTokenError → 401
  [ ] Converts JWT TokenExpiredError → 401
  [ ] Converts SyntaxError (body parser) → 400
  [ ] 404 catch-all: app.all('*', ...) registered before errorHandler

ENVIRONMENT-AWARE RESPONSES
  [ ] Development mode: full stack trace + raw error object in response
  [ ] Production mode: clean message only, no stack traces
  [ ] Programmer errors (isOperational=false): generic "Something went wrong"
  [ ] 5xx errors logged server-side with stack, URL, method, timestamp

PROCESS-LEVEL HANDLERS
  [ ] process.on('uncaughtException') registered at top of server.js
       → Log error with full stack trace
       → process.exit(1) immediately (state is corrupted)
  [ ] process.on('unhandledRejection') registered after server.listen()
       → Log error with full stack trace
       → server.close(() => process.exit(1)) for graceful shutdown
       → Safety timeout to force exit if server.close() hangs

DEPLOYMENT
  [ ] NODE_ENV=production set in production environment
  [ ] Process manager (PM2/Docker/K8s) auto-restarts crashed processes
  [ ] Error tracking service (Sentry/Datadog) for production error aggregation
  [ ] Alerting configured for 5xx error rate spikes
  [ ] No stack traces or internal file paths leaked in production responses
  [ ] Error responses have consistent JSON shape across ALL endpoints
  [ ] Integration tests cover all error scenarios

End of 3.17 quick revision.