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
| Term | One-liner |
|---|---|
| Operational error | An expected, recoverable error caused by external conditions -- not a bug (e.g., 404 not found, 400 validation failure, 401 expired token) |
| Programmer error | A bug in your code that should never happen in correct software (e.g., TypeError, ReferenceError, null dereference) |
| Error middleware | Express middleware with exactly 4 parameters (err, req, res, next) that catches all errors forwarded via next(err) |
| ApiError | A custom class extending Error that adds statusCode, isOperational, and status for consistent HTTP error responses |
| asyncHandler | A 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 Source | Detection | HTTP Code | Converted Message |
|---|---|---|---|
Mongoose CastError | err.name === 'CastError' | 400 | Invalid ${err.path}: ${err.value} |
Mongoose ValidationError | err.name === 'ValidationError' | 400 | Validation failed: ${field messages} |
| MongoDB duplicate key | err.code === 11000 | 409 | Duplicate value for '${field}' |
| JWT invalid token | err.name === 'JsonWebTokenError' | 401 | Invalid token. Please log in again. |
| JWT expired token | err.name === 'TokenExpiredError' | 401 | Your token has expired. Please log in again. |
| Express body parser | err instanceof SyntaxError && err.status === 400 | 400 | Invalid JSON in request body |
| Custom | err instanceof ApiError | varies | Custom message from constructor |
Common Patterns
| Pattern | Code / Description |
|---|---|
| Throw from route | throw new ApiError(404, 'Not found') inside asyncHandler-wrapped route |
| Forward with next | return next(new ApiError(400, 'Bad input')) in sync middleware |
| 404 catch-all | app.all('*', (req, res, next) => { next(new ApiError(404, \Cannot find ${req.originalUrl}`)); })` |
| Convert Mongoose errors | Check err.name / err.code in centralized handler, return new ApiError(...) |
| Async middleware | Wrap auth/validation middleware with asyncHandler just like routes |
| Multiple error handlers | Chain logErrors then sendErrorResponse, connected by next(err) |
| express-async-errors | require('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
| Feature | asyncHandler | express-async-errors | Express 5.x |
|---|---|---|---|
| Dependency | None (your own code) | npm package (~1KB) | Built-in |
| Usage | Wrap each route/middleware | require() once | Nothing extra |
| Explicitness | Explicit (visible) | Implicit (global patch) | Implicit (native) |
| Express 4 compatible | Yes | Yes | N/A |
| Express 5 compatible | Yes (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.