Error Handling — Custom Error Classes and Global Error Middleware

Error handling is where most beginners leave significant quality on the table. An application that crashes on an unhandled Mongoose validation error, or returns a raw stack trace to the client, or loses a Promise rejection silently, is not production-ready. A professional Express application has a complete, consistent error handling strategy: custom error classes that carry status codes, an asyncHandler wrapper that eliminates repetitive try/catch blocks, a single global error middleware that formats every error into the same JSON response shape, and process-level handlers that prevent silent crashes. This lesson builds that complete system.

Error Handling Layers in Express

Layer Handles Mechanism
Route-level try/catch Errors in async route handlers try { } catch(err) { next(err) }
asyncHandler wrapper Eliminates try/catch boilerplate Wraps async fn, catches rejections, calls next(err)
Global error middleware All errors passed via next(err) app.use((err, req, res, next) => {})
404 middleware Routes that do not match any handler app.use((req, res) => res.status(404).json(...))
process.on(‘uncaughtException’) Synchronous throws outside Express Process-level handler — should exit gracefully
process.on(‘unhandledRejection’) Promise rejections not caught Process-level handler — should exit gracefully

Common Error Types to Handle

Error Source Status Code Identification
Validation error Mongoose schema validators 400 err.name === 'ValidationError'
Duplicate key MongoDB unique index 409 err.code === 11000
Invalid ObjectId Mongoose CastError (bad _id) 400 err.name === 'CastError'
JWT expired jsonwebtoken library 401 err.name === 'TokenExpiredError'
JWT invalid jsonwebtoken library 401 err.name === 'JsonWebTokenError'
Not found Your application logic 404 Custom NotFoundError class
Unauthorised Your application logic 401 Custom UnauthorisedError class
Forbidden Your application logic 403 Custom ForbiddenError class
Note: The global error middleware in Express is identified by having exactly four parameters. Express calls it automatically when any middleware or route handler calls next(err) with a truthy value. It is always registered last in app.js, after all routes. The error object arrives as the first parameter. Any response not sent by a route handler must be sent by the error handler — if the error handler does not send a response, the client hangs.
Tip: Create a custom AppError class that extends the native Error. Add a statusCode property and an isOperational flag. Operational errors are expected failures (not found, validation error, unauthorised) — you can safely return their message to the client. Non-operational errors are programmer mistakes (null reference, logic bugs) — return a generic “Server Error” message in production and log the full details internally.
Warning: Never expose stack traces or internal error messages to API clients in production. Stack traces reveal your file structure, library versions, and logic flow — valuable information for attackers. Always check process.env.NODE_ENV === 'production' in your error handler and return a generic message for 500 errors. Reserve detailed error information for server-side logs (Morgan, Winston) where only you can see them.

Complete Error Handling System

// ── utils/AppError.js — custom error class ───────────────────────────────
class AppError extends Error {
    constructor(message, statusCode = 500, isOperational = true) {
        super(message);
        this.statusCode    = statusCode;
        this.isOperational = isOperational;   // true = expected, false = programmer bug
        this.name          = this.constructor.name;
        Error.captureStackTrace(this, this.constructor);
    }
}

class NotFoundError     extends AppError { constructor(msg = 'Resource not found') { super(msg, 404); } }
class UnauthorisedError extends AppError { constructor(msg = 'Authentication required') { super(msg, 401); } }
class ForbiddenError    extends AppError { constructor(msg = 'Access denied') { super(msg, 403); } }
class ValidationError   extends AppError { constructor(msg = 'Validation failed') { super(msg, 400); } }
class ConflictError     extends AppError { constructor(msg = 'Resource already exists') { super(msg, 409); } }

module.exports = { AppError, NotFoundError, UnauthorisedError, ForbiddenError, ValidationError, ConflictError };

// ── utils/asyncHandler.js ────────────────────────────────────────────────
const asyncHandler = fn => (req, res, next) =>
    Promise.resolve(fn(req, res, next)).catch(next);

module.exports = asyncHandler;

// ── middleware/errorHandler.js — global error middleware ──────────────────
const { AppError } = require('../utils/AppError');

const errorHandler = (err, req, res, next) => {
    let statusCode = err.statusCode || err.status || 500;
    let message    = err.message    || 'Internal Server Error';
    let errors     = [];

    // ── Mongoose validation error ─────────────────────────────────────────
    if (err.name === 'ValidationError') {
        statusCode = 400;
        errors     = Object.values(err.errors).map(e => ({
            field:   e.path,
            message: e.message,
        }));
        message = 'Validation failed';
    }

    // ── MongoDB duplicate key ─────────────────────────────────────────────
    if (err.code === 11000) {
        statusCode = 409;
        const field = Object.keys(err.keyPattern)[0];
        message     = `${field} already in use`;
    }

    // ── Mongoose bad ObjectId ─────────────────────────────────────────────
    if (err.name === 'CastError') {
        statusCode = 400;
        message    = `Invalid ${err.path}: '${err.value}' is not a valid ID`;
    }

    // ── JWT errors ────────────────────────────────────────────────────────
    if (err.name === 'TokenExpiredError') { statusCode = 401; message = 'Token expired'; }
    if (err.name === 'JsonWebTokenError') { statusCode = 401; message = 'Invalid token'; }

    // Log non-operational (programmer) errors
    if (!(err instanceof AppError) || !err.isOperational) {
        console.error('[UNHANDLED ERROR]', {
            message: err.message,
            stack:   err.stack,
            url:     req.originalUrl,
            method:  req.method,
            body:    req.body,
        });
    }

    const isProd = process.env.NODE_ENV === 'production';
    const isServerError = statusCode >= 500;

    res.status(statusCode).json({
        success: false,
        message: isProd && isServerError ? 'Internal Server Error' : message,
        ...(errors.length && { errors }),
        ...((!isProd) && { stack: err.stack }),
    });
};

module.exports = errorHandler;

// ── middleware/notFound.js ────────────────────────────────────────────────
const { NotFoundError } = require('../utils/AppError');

const notFound = (req, res, next) => {
    next(new NotFoundError(`Route not found: ${req.method} ${req.originalUrl}`));
};

module.exports = { notFound };

Using AppError in Controllers

// controllers/task.controller.js
const Task        = require('../models/task.model');
const asyncHandler = require('../utils/asyncHandler');
const { NotFoundError, ForbiddenError } = require('../utils/AppError');

// GET /api/tasks/:id
exports.getById = asyncHandler(async (req, res) => {
    const task = await Task.findById(req.params.id);

    if (!task) {
        throw new NotFoundError(`Task ${req.params.id} not found`);
        // asyncHandler catches this and calls next(err)
        // errorHandler receives it and sends 404 JSON response
    }

    if (task.user.toString() !== req.user.id) {
        throw new ForbiddenError('You do not have access to this task');
    }

    res.json({ success: true, data: task });
});

// POST /api/tasks
exports.create = asyncHandler(async (req, res) => {
    // If Mongoose validation fails, it throws a ValidationError
    // asyncHandler catches it and passes to errorHandler
    // errorHandler formats it as 400 with individual field errors
    const task = await Task.create({ ...req.body, user: req.user.id });
    res.status(201).json({ success: true, data: task });
});

// DELETE /api/tasks/:id
exports.remove = asyncHandler(async (req, res) => {
    const task = await Task.findOneAndDelete({
        _id:  req.params.id,
        user: req.user.id,
    });

    if (!task) throw new NotFoundError('Task not found or already deleted');
    res.status(204).end();
});

Process-Level Error Handlers

// index.js — add to your entry point

// Handle synchronous throws outside of Express routes
process.on('uncaughtException', (err) => {
    console.error('[uncaughtException]', err);
    // Give the server time to finish in-flight requests, then exit
    process.exit(1);
});

// Handle Promise rejections not caught with .catch()
process.on('unhandledRejection', (reason, promise) => {
    console.error('[unhandledRejection]', reason);
    // In Node 15+, unhandled rejections crash the process automatically
    // In earlier versions, you need to exit manually
    process.exit(1);
});

// Graceful shutdown on SIGTERM (Docker, Kubernetes, Heroku)
process.on('SIGTERM', () => {
    console.log('[SIGTERM] Shutting down gracefully');
    server.close(() => {
        mongoose.connection.close(false, () => {
            console.log('MongoDB connection closed');
            process.exit(0);
        });
    });
});

How It Works

Step 1 — Custom Error Classes Carry Status Codes

Extending the built-in Error class lets you create domain-specific error types that carry their own HTTP status code. When a controller throws new NotFoundError(), the error object already has statusCode: 404. The global error handler reads this property directly without needing a complex switch statement to map error messages to status codes.

Step 2 — asyncHandler Eliminates Boilerplate

Without asyncHandler, every async route handler needs a try/catch block that calls next(err). With 20 routes, that is 20 identical try/catch blocks. asyncHandler wraps the function in a Promise, attaches a .catch(next), and returns the wrapped function. Any thrown error or rejected Promise is automatically forwarded to Express’s error pipeline.

Step 3 — The Global Error Handler Is the Single Error Exit Point

All errors — from validation failures to database timeouts to missing records — flow through the single error handler. This guarantees that every error response has the same JSON structure: { success: false, message: '...' }. Angular’s error handling code only needs to read one property to get the error message regardless of which endpoint failed.

Step 4 — Mongoose Errors Are Normalised in the Error Handler

Mongoose throws its own error types with specific name and code properties. The global error handler checks for these and converts them to appropriate HTTP status codes. A Mongoose ValidationError becomes a 400 with field-by-field error details. A duplicate key error (code 11000) becomes a 409 with the conflicting field name.

Step 5 — Process-Level Handlers Prevent Silent Crashes

Even with perfect Express error handling, errors can escape the middleware chain — in startup code, in third-party callbacks that are not promise-based, in event emitters. The uncaughtException and unhandledRejection process handlers are the last line of defence. They log the error (so you can debug it) and exit the process (so a process manager like PM2 can restart it cleanly).

Real-World Example: End-to-End Error Flow

POST /api/auth/register  { email: "alice@example.com", password: "123" }

1. authenticate middleware — no token needed for register, skip
2. validateBody middleware — password too short, next(new ValidationError(...))
3. Skips all remaining middleware
4. errorHandler receives: ValidationError { statusCode: 400, message: 'Password too weak' }
5. Response:
   HTTP 400
   { "success": false, "message": "Validation failed",
     "errors": [{ "field": "password", "message": "Password must be at least 8 characters" }] }

---

DELETE /api/tasks/not-a-valid-id  (invalid ObjectId format)

1. authenticate middleware — verifies JWT, sets req.user
2. controller.remove — await Task.findOneAndDelete({ _id: 'not-a-valid-id' })
   Mongoose throws CastError { name: 'CastError', path: '_id', value: 'not-a-valid-id' }
3. asyncHandler catches the thrown CastError, calls next(castError)
4. errorHandler receives CastError, maps to 400
5. Response:
   HTTP 400
   { "success": false, "message": "Invalid _id: 'not-a-valid-id' is not a valid ID" }

Common Mistakes

Mistake 1 — Not passing error to next() in async handlers

❌ Wrong — error is swallowed, client hangs:

app.get('/tasks/:id', async (req, res) => {
    const task = await Task.findById(req.params.id);  // CastError thrown
    // No try/catch — CastError is an unhandled rejection — client hangs!
    res.json(task);
});

✅ Correct — use asyncHandler or explicit try/catch:

app.get('/tasks/:id', asyncHandler(async (req, res) => {
    const task = await Task.findById(req.params.id);  // CastError caught by asyncHandler
    res.json(task);
}));

Mistake 2 — Leaking stack traces to clients in production

❌ Wrong — exposes internal details:

app.use((err, req, res, next) => {
    res.status(500).json({ message: err.message, stack: err.stack });  // never do this in prod!
});

✅ Correct — hide details in production:

app.use((err, req, res, next) => {
    const isProd = process.env.NODE_ENV === 'production';
    res.status(err.status || 500).json({
        message: isProd && !err.isOperational ? 'Server Error' : err.message,
        ...(isProd ? {} : { stack: err.stack }),
    });
});

Mistake 3 — Registering the error handler before routes

❌ Wrong — error handler registered before routes never receives their errors:

app.use(errorHandler);  // registered first — only catches errors in middleware above it
app.use('/api', routes); // errors here never reach the handler above

✅ Correct — error handler is always the last thing registered:

app.use('/api', routes);
app.use(notFound);      // 404 handler
app.use(errorHandler);  // LAST — catches all errors from everything above

Quick Reference

Task Code
Custom error throw new NotFoundError('Task not found')
Forward to error handler next(new AppError('msg', 400))
Wrap async route asyncHandler(async (req, res) => { ... })
Error handler signature (err, req, res, next) — 4 params required
Mongoose validation err.name === 'ValidationError' → 400
Duplicate key err.code === 11000 → 409
Bad ObjectId err.name === 'CastError' → 400
404 middleware app.use((req, res) => res.status(404).json(...))
Process crash guard process.on('unhandledRejection', handler)

🧠 Test Yourself

A Mongoose query throws a CastError because the route received an invalid ObjectId. Using asyncHandler, where does this error end up and what response should the client receive?