Node.js

Error Handling

Robust error handling patterns for Node.js — custom error classes, async wrapper utilities, Express global error middleware, Winston structured logging, and process-level safety nets.

Custom AppError Class

Extend the native Error class to carry an HTTP status code and an isOperational flag. Operational errors (e.g. 404, validation) are safe to expose to clients; programmer errors crash the process.

// lib/AppError.js
class AppError extends Error {
    /**
     * @param {string} message        - human-readable description
     * @param {number} statusCode     - HTTP status (e.g. 400, 404, 422)
     * @param {string} [code]         - machine-readable error code
     * @param {object} [details]      - extra payload (validation errors, etc.)
     */
    constructor(message, statusCode = 500, code = 'INTERNAL_ERROR', details = null) {
        super(message);
        this.name          = 'AppError';
        this.statusCode    = statusCode;
        this.code          = code;
        this.details       = details;
        this.isOperational = true;          // flag: safe to expose to client
        Error.captureStackTrace(this, this.constructor);
    }
}

// Convenience factories
AppError.notFound    = (msg = 'Resource not found') =>
    new AppError(msg, 404, 'NOT_FOUND');

AppError.unauthorized = (msg = 'Unauthorized') =>
    new AppError(msg, 401, 'UNAUTHORIZED');

AppError.forbidden    = (msg = 'Forbidden') =>
    new AppError(msg, 403, 'FORBIDDEN');

AppError.badRequest   = (msg, details) =>
    new AppError(msg, 400, 'BAD_REQUEST', details);

AppError.conflict     = (msg) =>
    new AppError(msg, 409, 'CONFLICT');

module.exports = AppError;

Async Error Wrapper (catchAsync)

Instead of wrapping every async route in a try/catch, use a higher-order function to forward errors to Express's next() automatically.

// lib/catchAsync.js
/**
 * Wraps an async Express route handler and forwards any rejection to next().
 * Usage: router.get('/path', catchAsync(async (req, res) => { ... }))
 */
const catchAsync = (fn) => (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
};

module.exports = catchAsync;

// ── Example usage in a route ──────────────────────────────────
const catchAsync = require('../lib/catchAsync');
const AppError   = require('../lib/AppError');

router.get('/users/:id', catchAsync(async (req, res) => {
    const user = await User.findById(req.params.id);
    if (!user) throw AppError.notFound(`User ${req.params.id} not found`);
    res.json(user);
}));

router.post('/users', catchAsync(async (req, res) => {
    const user = await User.create(req.body);    // validation throws AppError
    res.status(201).json(user);
}));

Global Express Error Handling Middleware

Register this as the last middleware (after all routes). It catches everything forwarded via next(err). Never expose stack traces in production.

// middleware/errorHandler.js
const AppError = require('../lib/AppError');
const logger   = require('../lib/logger');

// eslint-disable-next-line no-unused-vars
module.exports = function errorHandler(err, req, res, next) {
    // Normalize non-AppError errors
    if (!(err instanceof AppError)) {
        logger.error('Unhandled error', {
            message: err.message,
            stack:   err.stack,
            url:     req.originalUrl,
            method:  req.method,
        });
        err = new AppError('Something went wrong', 500);
    } else {
        logger.warn('Operational error', {
            code:    err.code,
            message: err.message,
            url:     req.originalUrl,
        });
    }

    const response = {
        status:  'error',
        code:    err.code,
        message: err.message,
    };

    if (err.details)                         response.details = err.details;
    if (process.env.NODE_ENV !== 'production') response.stack  = err.stack;

    res.status(err.statusCode).json(response);
};

// ── Register in app.js (after all routes) ─────────────────────
// const errorHandler = require('./middleware/errorHandler');
// app.use(errorHandler);

Winston Structured Logger

Configure Winston to write JSON logs in production (easy to parse by log aggregators) and colourized text in development.

npm install winston

// lib/logger.js
const { createLogger, format, transports } = require('winston');
const { combine, timestamp, errors, json, colorize, simple } = format;

const isProd = process.env.NODE_ENV === 'production';

const logger = createLogger({
    level: process.env.LOG_LEVEL || (isProd ? 'info' : 'debug'),
    format: isProd
        ? combine(timestamp(), errors({ stack: true }), json())
        : combine(colorize(), timestamp({ format: 'HH:mm:ss' }), errors({ stack: true }), simple()),
    defaultMeta: { service: 'api' },
    transports: [
        new transports.Console(),
        ...(isProd ? [
            new transports.File({ filename: 'logs/error.log', level: 'error' }),
            new transports.File({ filename: 'logs/combined.log' }),
        ] : []),
    ],
    exceptionHandlers: [new transports.File({ filename: 'logs/exceptions.log' })],
    rejectionHandlers: [new transports.File({ filename: 'logs/rejections.log' })],
});

module.exports = logger;

// Usage:
// logger.info('Server started', { port: 3000 });
// logger.error('DB connection failed', { error: err.message });
// logger.warn('Cache miss', { key });

Process-Level Safety Nets

Catch any errors that slip through try/catch or Promise chains. Log them, then exit cleanly so the process manager (PM2, Docker) can restart.

// server.js  (entry point – register these before anything else)
const logger = require('./lib/logger');

process.on('uncaughtException', (err) => {
    logger.error('UNCAUGHT EXCEPTION – shutting down', {
        name:    err.name,
        message: err.message,
        stack:   err.stack,
    });
    // Give logger time to flush, then exit
    process.exit(1);
});

process.on('unhandledRejection', (reason, promise) => {
    logger.error('UNHANDLED REJECTION – shutting down', {
        promise,
        reason: reason?.message || reason,
        stack:  reason?.stack,
    });
    // Throw to trigger uncaughtException handler above
    throw reason;
});

// ── Start server after registering handlers ───────────────────
const app    = require('./app');
const server = app.listen(process.env.PORT || 3000, () =>
    logger.info(`Server listening on port ${process.env.PORT || 3000}`)
);

// Graceful shutdown on SIGTERM (Docker, PM2)
process.on('SIGTERM', () => {
    logger.info('SIGTERM received – closing HTTP server');
    server.close(() => {
        logger.info('HTTP server closed');
        process.exit(0);
    });
});