Nodejs

Node.js Error Handling Strategies for Production

A production-focused guide to Node.js error handling covering custom error classes, Express.js error middleware, async/await patterns, graceful shutdown, and centralized error architecture.

Node.js Error Handling Strategies for Production

Overview

Error handling is the difference between a Node.js application that crashes at 3 AM and one that degrades gracefully, logs actionable information, and recovers on its own. Most tutorials cover try/catch and call it done -- but production systems need custom error hierarchies, centralized handling, graceful shutdown, retry logic, and structured logging. This article covers all of it, with patterns I have shipped in real production systems serving millions of requests.

Prerequisites

  • Node.js 18+ installed
  • Working knowledge of Express.js routing and middleware
  • Familiarity with Promises and async/await
  • Basic understanding of process signals (SIGTERM, SIGINT)

The Error Hierarchy in Node.js

Every error in Node.js inherits from the built-in Error class. Understanding the hierarchy helps you catch the right errors at the right level.

// The built-in error types
var err1 = new Error('Something went wrong');           // Generic error
var err2 = new TypeError('Expected a string');          // Wrong type
var err3 = new RangeError('Index out of bounds');       // Value out of range
var err4 = new SyntaxError('Unexpected token');         // Parse errors
var err5 = new ReferenceError('x is not defined');      // Undefined variable

console.log(err1 instanceof Error);     // true
console.log(err2 instanceof Error);     // true -- TypeError IS an Error
console.log(err2 instanceof TypeError); // true

The key insight: every built-in error type is an Error. When you catch Error, you catch everything. When you catch TypeError, you only catch type errors. This matters when you start building custom error classes.

What the Stack Trace Tells You

var err = new Error('Database connection failed');
console.log(err.stack);
Error: Database connection failed
    at Object.<anonymous> (/app/services/db.js:45:15)
    at Module._compile (node:internal/modules/cjs/loader:1256:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1310:10)
    at Module.load (node:internal/modules/cjs/loader:1119:32)
    at Module._resolveFilename (node:internal/modules/cjs/loader:1080:15)

The stack trace captures where the error was created, not where it was thrown. This distinction matters when you create errors in one place and throw them in another. Always create errors as close to the failure point as possible.


Synchronous vs Asynchronous Error Patterns

Node.js has fundamentally different error propagation depending on whether code is synchronous or asynchronous. Getting this wrong is one of the most common sources of unhandled errors in production.

Synchronous Errors

Synchronous errors are straightforward -- try/catch works exactly as expected:

function parseConfig(jsonString) {
    try {
        var config = JSON.parse(jsonString);
        if (!config.port) {
            throw new Error('Config missing required field: port');
        }
        return config;
    } catch (err) {
        console.error('Failed to parse config:', err.message);
        throw err; // Re-throw if caller needs to handle it
    }
}

Asynchronous Errors -- The Trap

Try/catch does not catch errors in callbacks or unhandled promise rejections:

// THIS DOES NOT WORK
try {
    setTimeout(function() {
        throw new Error('This escapes the try/catch');
    }, 100);
} catch (err) {
    // Never reached. The error crashes the process.
    console.error('Caught:', err.message);
}

The setTimeout callback runs in a different tick of the event loop. By the time it throws, the try/catch frame is long gone. This is why Node.js has different mechanisms for async error handling.


Error-First Callbacks

The original Node.js pattern. Every callback receives an error as its first argument:

var fs = require('fs');

fs.readFile('/etc/config.json', 'utf8', function(err, data) {
    if (err) {
        if (err.code === 'ENOENT') {
            console.error('Config file not found:', err.path);
            return;
        }
        console.error('Failed to read config:', err.message);
        return;
    }
    console.log('Config loaded:', data.length, 'bytes');
});

The rules are simple: always check err first, always return after handling it, never ignore it. The most dangerous bug in callback code is forgetting to return after an error -- the function continues executing with undefined data.

// BUG: Missing return after error check
function getUser(id, callback) {
    db.query('SELECT * FROM users WHERE id = $1', [id], function(err, result) {
        if (err) {
            callback(err);
            // Missing return! Code below still runs with undefined result
        }
        callback(null, result.rows[0]); // TypeError: Cannot read property 'rows' of undefined
    });
}

Error Handling with Promises

Promises centralize error handling through .catch():

var axios = require('axios');

function fetchUserProfile(userId) {
    return axios.get('https://api.example.com/users/' + userId)
        .then(function(response) {
            return response.data;
        })
        .catch(function(err) {
            if (err.response) {
                // Server responded with an error status
                console.error('API error:', err.response.status, err.response.data);
            } else if (err.request) {
                // Request made but no response received
                console.error('No response from API:', err.message);
            } else {
                // Something went wrong setting up the request
                console.error('Request setup failed:', err.message);
            }
            throw err; // Re-throw for upstream handling
        });
}

The Unhandled Rejection Problem

If a Promise rejects and there is no .catch() handler, Node.js emits an unhandledRejection event. Starting with Node.js 15, unhandled rejections terminate the process by default.

// This will crash your process in Node 15+
function riskyOperation() {
    return Promise.reject(new Error('Something failed'));
}

riskyOperation(); // No .catch() -- process exits with code 1
node:internal/process/promises:288
            triggerUncaughtException(err, true /* fromPromise */);
            ^

[UnhandledPromiseRejection: This error originated either by throwing inside of an
async function without a catch block, or by rejecting a promise which was not handled
with .catch(). The promise rejected with the reason "Something failed".]

Always attach .catch() to every promise chain, or use async/await with try/catch.


Error Handling with Async/Await

Async/await makes error handling look synchronous again, but there are still pitfalls:

async function processOrder(orderId) {
    try {
        var order = await db.getOrder(orderId);
        var inventory = await inventoryService.check(order.items);
        var payment = await paymentService.charge(order.total, order.paymentMethod);
        var confirmation = await emailService.sendConfirmation(order.email, payment.id);
        return { order: order, payment: payment, confirmation: confirmation };
    } catch (err) {
        // All errors from any await land here
        console.error('Order processing failed for', orderId, ':', err.message);
        throw err;
    }
}

Parallel Async Operations

When running multiple async operations in parallel with Promise.all, a single failure rejects the entire batch. Use Promise.allSettled when you need partial results:

async function loadDashboard(userId) {
    var results = await Promise.allSettled([
        fetchProfile(userId),
        fetchNotifications(userId),
        fetchRecentActivity(userId)
    ]);

    var dashboard = {};
    results.forEach(function(result, index) {
        var keys = ['profile', 'notifications', 'activity'];
        if (result.status === 'fulfilled') {
            dashboard[keys[index]] = result.value;
        } else {
            console.error(keys[index] + ' failed:', result.reason.message);
            dashboard[keys[index]] = null; // Graceful degradation
        }
    });

    return dashboard;
}

Express.js Error Middleware

Express recognizes error-handling middleware by its four-argument signature: (err, req, res, next). Remove any one of those parameters and Express treats it as regular middleware -- your errors vanish silently.

// CORRECT: Four parameters -- Express treats this as error middleware
app.use(function(err, req, res, next) {
    console.error(err.stack);
    res.status(500).json({ error: 'Internal Server Error' });
});

// WRONG: Three parameters -- Express treats this as regular middleware
app.use(function(err, req, res) {
    // This never receives errors. It runs on every request.
    res.status(500).json({ error: 'Internal Server Error' });
});

Error middleware must be registered after all routes and other middleware:

var express = require('express');
var app = express();

// Routes first
app.get('/api/users', getUsers);
app.post('/api/users', createUser);

// Then 404 handler
app.use(function(req, res, next) {
    var err = new Error('Not Found');
    err.statusCode = 404;
    next(err);
});

// Then error middleware -- LAST
app.use(function(err, req, res, next) {
    res.status(err.statusCode || 500).json({
        error: err.message
    });
});

Custom Error Classes

Generic Error objects force you to inspect .message strings to figure out what went wrong. Custom error classes give you typed errors with structured metadata.

function AppError(message, statusCode, errorCode, details) {
    Error.call(this, message);
    this.name = 'AppError';
    this.message = message;
    this.statusCode = statusCode || 500;
    this.errorCode = errorCode || 'INTERNAL_ERROR';
    this.details = details || null;
    this.isOperational = true;
    this.timestamp = new Date().toISOString();
    Error.captureStackTrace(this, AppError);
}
AppError.prototype = Object.create(Error.prototype);
AppError.prototype.constructor = AppError;

function ValidationError(message, fields) {
    AppError.call(this, message, 400, 'VALIDATION_ERROR', { fields: fields });
    this.name = 'ValidationError';
    Error.captureStackTrace(this, ValidationError);
}
ValidationError.prototype = Object.create(AppError.prototype);
ValidationError.prototype.constructor = ValidationError;

function NotFoundError(resource, identifier) {
    var msg = resource + ' not found' + (identifier ? ': ' + identifier : '');
    AppError.call(this, msg, 404, 'NOT_FOUND', { resource: resource, identifier: identifier });
    this.name = 'NotFoundError';
    Error.captureStackTrace(this, NotFoundError);
}
NotFoundError.prototype = Object.create(AppError.prototype);
NotFoundError.prototype.constructor = NotFoundError;

function AuthenticationError(message) {
    AppError.call(this, message || 'Authentication required', 401, 'AUTHENTICATION_REQUIRED');
    this.name = 'AuthenticationError';
    Error.captureStackTrace(this, AuthenticationError);
}
AuthenticationError.prototype = Object.create(AppError.prototype);
AuthenticationError.prototype.constructor = AuthenticationError;

function RateLimitError(retryAfter) {
    AppError.call(this, 'Too many requests', 429, 'RATE_LIMIT_EXCEEDED', { retryAfter: retryAfter });
    this.name = 'RateLimitError';
    Error.captureStackTrace(this, RateLimitError);
}
RateLimitError.prototype = Object.create(AppError.prototype);
RateLimitError.prototype.constructor = RateLimitError;

Now your route handlers read clearly:

app.get('/api/users/:id', async function(req, res, next) {
    try {
        var user = await db.getUserById(req.params.id);
        if (!user) {
            throw new NotFoundError('User', req.params.id);
        }
        res.json(user);
    } catch (err) {
        next(err);
    }
});

app.post('/api/users', async function(req, res, next) {
    try {
        var errors = {};
        if (!req.body.email) errors.email = 'Email is required';
        if (!req.body.name) errors.name = 'Name is required';
        if (Object.keys(errors).length > 0) {
            throw new ValidationError('Invalid input', errors);
        }
        var user = await db.createUser(req.body);
        res.status(201).json(user);
    } catch (err) {
        next(err);
    }
});

Operational Errors vs Programmer Errors

This is the most important distinction in Node.js error handling, and most developers get it wrong.

Operational errors are expected failures in normal operation: a database connection timeout, a file not found, invalid user input, a third-party API returning 503. These are not bugs. Your application should handle them gracefully.

Programmer errors are bugs in your code: reading a property of undefined, passing a string where a number is expected, an off-by-one error. These indicate code that needs to be fixed.

// Operational error -- handle it
try {
    var data = await fetch('https://api.stripe.com/v1/charges');
} catch (err) {
    if (err.code === 'ECONNREFUSED') {
        // Expected failure, retry or use fallback
        return useCachedData();
    }
}

// Programmer error -- this is a bug
var user = null;
console.log(user.name); // TypeError: Cannot read properties of null (reading 'name')

The rule: recover from operational errors, crash on programmer errors. A programmer error means your process is in an undefined state. Trying to recover from an undefined state leads to data corruption, silent failures, and cascading bugs that are far harder to debug than a clean crash.

The isOperational flag on our AppError class marks the difference:

function handleError(err) {
    if (err.isOperational) {
        // Known, expected failure -- log and respond gracefully
        logger.warn('Operational error:', { code: err.errorCode, message: err.message });
        return true; // Handled
    }

    // Programmer error or unknown failure -- log and crash
    logger.error('Programmer error or unknown failure:', { error: err.message, stack: err.stack });
    return false; // Not handled -- process should exit
}

Centralized Error Handling Architecture

Scattering try/catch blocks and error responses across every route is a maintenance nightmare. Centralize everything.

The Async Route Wrapper

Every async route handler needs to catch errors and pass them to next(). Instead of writing try/catch in every handler, use a wrapper:

function asyncHandler(fn) {
    return function(req, res, next) {
        Promise.resolve(fn(req, res, next)).catch(next);
    };
}

// Usage -- no try/catch needed in the route handler
app.get('/api/users/:id', asyncHandler(async function(req, res) {
    var user = await db.getUserById(req.params.id);
    if (!user) throw new NotFoundError('User', req.params.id);
    res.json(user);
}));

app.post('/api/orders', asyncHandler(async function(req, res) {
    var order = await orderService.create(req.body);
    res.status(201).json(order);
}));

Any error thrown inside the handler -- whether synchronous or from a rejected promise -- automatically flows to your error middleware. No manual try/catch, no forgotten next(err) calls.

The Central Error Middleware

function errorMiddleware(err, req, res, next) {
    // Default values
    var statusCode = err.statusCode || 500;
    var errorCode = err.errorCode || 'INTERNAL_ERROR';
    var message = err.message || 'An unexpected error occurred';

    // Log the error
    if (statusCode >= 500) {
        logger.error('Server error', {
            errorCode: errorCode,
            message: message,
            stack: err.stack,
            method: req.method,
            url: req.originalUrl,
            ip: req.ip,
            userId: req.user ? req.user.id : null,
            body: sanitizeBody(req.body),
            requestId: req.headers['x-request-id']
        });
    } else {
        logger.warn('Client error', {
            errorCode: errorCode,
            message: message,
            method: req.method,
            url: req.originalUrl
        });
    }

    // Build response
    var response = {
        error: {
            code: errorCode,
            message: process.env.NODE_ENV === 'production' && statusCode === 500
                ? 'An internal error occurred'
                : message
        }
    };

    // Include validation details for 400 errors
    if (err.details) {
        response.error.details = err.details;
    }

    // Include request ID for tracing
    if (req.headers['x-request-id']) {
        response.error.requestId = req.headers['x-request-id'];
    }

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

function sanitizeBody(body) {
    if (!body) return null;
    var sanitized = Object.assign({}, body);
    var sensitiveFields = ['password', 'token', 'secret', 'authorization', 'creditCard', 'ssn'];
    sensitiveFields.forEach(function(field) {
        if (sanitized[field]) sanitized[field] = '[REDACTED]';
    });
    return sanitized;
}

Graceful Shutdown

When your process receives SIGTERM (from a container orchestrator, load balancer, or deployment), or when an uncaughtException occurs, you need to shut down cleanly: stop accepting new connections, finish in-flight requests, close database connections, then exit.

var http = require('http');

var server;
var connections = new Set();

function startServer(app, port) {
    server = http.createServer(app);

    server.on('connection', function(conn) {
        connections.add(conn);
        conn.on('close', function() {
            connections.delete(conn);
        });
    });

    server.listen(port, function() {
        console.log('Server listening on port ' + port);
    });

    return server;
}

function gracefulShutdown(signal) {
    console.log(signal + ' received. Starting graceful shutdown...');

    // Stop accepting new connections
    server.close(function() {
        console.log('HTTP server closed. All pending requests completed.');
        // Close database connections, flush logs, etc.
        closeResources().then(function() {
            console.log('All resources released. Exiting.');
            process.exit(0);
        });
    });

    // Force-close connections that are idle (keep-alive)
    connections.forEach(function(conn) {
        conn.end();
    });

    // If graceful shutdown takes too long, force exit
    setTimeout(function() {
        console.error('Graceful shutdown timed out. Forcing exit.');
        process.exit(1);
    }, 30000); // 30 second timeout
}

function closeResources() {
    return Promise.all([
        db.close(),
        cache.quit(),
        messageQueue.close()
    ]).catch(function(err) {
        console.error('Error closing resources:', err.message);
    });
}

// Signal handlers
process.on('SIGTERM', function() { gracefulShutdown('SIGTERM'); });
process.on('SIGINT', function() { gracefulShutdown('SIGINT'); });

// Uncaught exception -- log and exit
process.on('uncaughtException', function(err) {
    console.error('Uncaught Exception:', err.message);
    console.error(err.stack);
    gracefulShutdown('uncaughtException');
});

// Unhandled rejection -- log and exit
process.on('unhandledRejection', function(reason, promise) {
    console.error('Unhandled Rejection at:', promise);
    console.error('Reason:', reason);
    gracefulShutdown('unhandledRejection');
});

Do not try to recover from uncaughtException. The Node.js documentation is explicit about this: after an uncaught exception, your process state is unreliable. Log the error, shut down gracefully, and let your process manager (PM2, systemd, Kubernetes) restart the process.


Error Logging Strategies

What you log and how you log it directly affects your ability to debug production issues at 3 AM.

What to Log

var logger = {
    error: function(message, meta) {
        var entry = {
            level: 'error',
            message: message,
            timestamp: new Date().toISOString(),
            // Context
            requestId: meta.requestId,
            userId: meta.userId,
            method: meta.method,
            url: meta.url,
            // Error details
            errorCode: meta.errorCode,
            errorMessage: meta.message,
            stack: meta.stack,
            // System
            pid: process.pid,
            hostname: require('os').hostname(),
            nodeVersion: process.version,
            memoryUsage: process.memoryUsage().heapUsed
        };
        console.error(JSON.stringify(entry));
    }
};

What NOT to Log

Never log these values -- they end up in log aggregation systems, get indexed, and create security and compliance violations:

  • Passwords, tokens, API keys, secrets
  • Full credit card numbers or SSNs
  • Session tokens or JWTs (log a truncated hash instead)
  • PII beyond what is necessary for debugging (email is usually fine, full address is not)
// BAD: Logging the full request body
logger.error('Login failed', { body: req.body }); // Logs password in plaintext

// GOOD: Logging only what you need
logger.error('Login failed', { email: req.body.email, ip: req.ip });

Structured Logging

Always use JSON for log output. Structured logs are searchable, filterable, and parseable by log aggregation tools like ELK, Datadog, and CloudWatch.

{
  "level": "error",
  "message": "Database query failed",
  "timestamp": "2026-02-08T14:32:01.123Z",
  "requestId": "req-abc-123",
  "userId": "user_42",
  "method": "GET",
  "url": "/api/orders/789",
  "errorCode": "DB_QUERY_ERROR",
  "query": "SELECT * FROM orders WHERE id = $1",
  "duration": 5023,
  "pid": 12345
}

Error Response Formatting for APIs

Consistent error responses let your frontend team build reliable error handling. Pick a format and stick with it across every endpoint.

// Consistent error response shape
// {
//   "error": {
//     "code": "VALIDATION_ERROR",
//     "message": "Invalid input",
//     "details": { "fields": { "email": "Invalid email format" } },
//     "requestId": "req-abc-123"
//   }
// }

function formatErrorResponse(err, requestId) {
    var response = {
        error: {
            code: err.errorCode || 'INTERNAL_ERROR',
            message: err.isOperational ? err.message : 'An internal error occurred'
        }
    };

    if (err.details) {
        response.error.details = err.details;
    }

    if (requestId) {
        response.error.requestId = requestId;
    }

    return response;
}

Real examples of well-structured error responses:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid input",
    "details": {
      "fields": {
        "email": "Must be a valid email address",
        "age": "Must be a positive integer"
      }
    },
    "requestId": "req-7f3a2b1c"
  }
}
{
  "error": {
    "code": "NOT_FOUND",
    "message": "User not found: usr_abc123",
    "requestId": "req-9e8d7c6b"
  }
}
{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Too many requests",
    "details": {
      "retryAfter": 30
    },
    "requestId": "req-1a2b3c4d"
  }
}

Handling Third-Party API Errors

Third-party APIs fail in surprising ways. Wrap every external call with error normalization:

var axios = require('axios');

function callExternalAPI(method, url, data, options) {
    var config = {
        method: method,
        url: url,
        data: data,
        timeout: (options && options.timeout) || 10000,
        headers: (options && options.headers) || {}
    };

    return axios(config)
        .then(function(response) {
            return response.data;
        })
        .catch(function(err) {
            if (err.response) {
                // The API responded with an error
                var apiError = new AppError(
                    'External API error: ' + (err.response.data.message || err.response.statusText),
                    mapExternalStatus(err.response.status),
                    'EXTERNAL_API_ERROR',
                    {
                        service: url,
                        status: err.response.status,
                        responseBody: err.response.data
                    }
                );
                throw apiError;
            }

            if (err.code === 'ECONNABORTED') {
                throw new AppError('External API timeout', 504, 'EXTERNAL_API_TIMEOUT', {
                    service: url,
                    timeout: config.timeout
                });
            }

            if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND') {
                throw new AppError('External API unavailable', 503, 'EXTERNAL_API_UNAVAILABLE', {
                    service: url,
                    code: err.code
                });
            }

            throw new AppError('External API request failed: ' + err.message, 500, 'EXTERNAL_API_FAILURE');
        });
}

function mapExternalStatus(externalStatus) {
    // Do not expose third-party 401/403 as your own
    if (externalStatus === 401 || externalStatus === 403) return 502;
    if (externalStatus === 404) return 404;
    if (externalStatus === 429) return 503;
    if (externalStatus >= 500) return 502;
    return 500;
}

Retry Patterns for Transient Failures

Network failures, database connection blips, and rate-limited APIs are all transient. Retrying with exponential backoff handles these without overwhelming the failing service.

function retryWithBackoff(fn, options) {
    var maxRetries = (options && options.maxRetries) || 3;
    var baseDelay = (options && options.baseDelay) || 1000;
    var maxDelay = (options && options.maxDelay) || 30000;
    var retryableErrors = (options && options.retryableErrors) || ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE'];

    return new Promise(function(resolve, reject) {
        var attempt = 0;

        function tryOnce() {
            attempt++;
            Promise.resolve(fn())
                .then(resolve)
                .catch(function(err) {
                    var isRetryable = retryableErrors.indexOf(err.code) !== -1
                        || (err.response && err.response.status === 429)
                        || (err.response && err.response.status >= 500);

                    if (!isRetryable || attempt >= maxRetries) {
                        err.attempts = attempt;
                        return reject(err);
                    }

                    // Exponential backoff with jitter
                    var delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
                    var jitter = Math.floor(Math.random() * delay * 0.1);
                    var totalDelay = delay + jitter;

                    console.log('Retry attempt ' + attempt + '/' + maxRetries + ' after ' + totalDelay + 'ms');

                    setTimeout(tryOnce, totalDelay);
                });
        }

        tryOnce();
    });
}

// Usage
async function fetchWithRetry(url) {
    return retryWithBackoff(
        function() { return axios.get(url); },
        { maxRetries: 3, baseDelay: 1000 }
    );
}

The jitter prevents the "thundering herd" problem -- if 100 requests all fail at the same time and retry at exactly the same interval, they overwhelm the recovering service again.

Circuit Breaker Pattern

When retries are not enough, a circuit breaker stops calling a failing service entirely until it recovers:

function CircuitBreaker(options) {
    this.failureThreshold = (options && options.failureThreshold) || 5;
    this.resetTimeout = (options && options.resetTimeout) || 60000;
    this.state = 'CLOSED'; // CLOSED = normal, OPEN = failing, HALF_OPEN = testing
    this.failureCount = 0;
    this.lastFailureTime = null;
    this.nextAttempt = null;
}

CircuitBreaker.prototype.call = function(fn) {
    var self = this;

    if (self.state === 'OPEN') {
        if (Date.now() < self.nextAttempt) {
            return Promise.reject(new AppError(
                'Circuit breaker is open. Service unavailable.',
                503,
                'CIRCUIT_OPEN',
                { retryAfter: Math.ceil((self.nextAttempt - Date.now()) / 1000) }
            ));
        }
        self.state = 'HALF_OPEN';
    }

    return Promise.resolve(fn())
        .then(function(result) {
            if (self.state === 'HALF_OPEN') {
                self.state = 'CLOSED';
                self.failureCount = 0;
                console.log('Circuit breaker closed. Service recovered.');
            }
            return result;
        })
        .catch(function(err) {
            self.failureCount++;
            self.lastFailureTime = Date.now();

            if (self.failureCount >= self.failureThreshold) {
                self.state = 'OPEN';
                self.nextAttempt = Date.now() + self.resetTimeout;
                console.error('Circuit breaker opened. Failures: ' + self.failureCount);
            }

            throw err;
        });
};

// Usage
var paymentCircuit = new CircuitBreaker({ failureThreshold: 3, resetTimeout: 30000 });

async function chargeCustomer(amount, paymentMethod) {
    return paymentCircuit.call(function() {
        return paymentService.charge(amount, paymentMethod);
    });
}

Complete Working Example

Here is a production Express.js application with all of the patterns tied together: custom error classes, async route wrapper, centralized error middleware, graceful shutdown, and structured logging.

// app.js -- Production Error Handling Example
var express = require('express');
var http = require('http');
var os = require('os');

// ============================================================
// Custom Error Classes
// ============================================================

function AppError(message, statusCode, errorCode, details) {
    Error.call(this, message);
    this.name = 'AppError';
    this.message = message;
    this.statusCode = statusCode || 500;
    this.errorCode = errorCode || 'INTERNAL_ERROR';
    this.details = details || null;
    this.isOperational = true;
    this.timestamp = new Date().toISOString();
    Error.captureStackTrace(this, AppError);
}
AppError.prototype = Object.create(Error.prototype);
AppError.prototype.constructor = AppError;

function ValidationError(message, fields) {
    AppError.call(this, message, 400, 'VALIDATION_ERROR', { fields: fields });
    this.name = 'ValidationError';
    Error.captureStackTrace(this, ValidationError);
}
ValidationError.prototype = Object.create(AppError.prototype);
ValidationError.prototype.constructor = ValidationError;

function NotFoundError(resource, identifier) {
    var msg = resource + ' not found' + (identifier ? ': ' + identifier : '');
    AppError.call(this, msg, 404, 'NOT_FOUND', { resource: resource, identifier: identifier });
    this.name = 'NotFoundError';
    Error.captureStackTrace(this, NotFoundError);
}
NotFoundError.prototype = Object.create(AppError.prototype);
NotFoundError.prototype.constructor = NotFoundError;

function AuthenticationError(message) {
    AppError.call(this, message || 'Authentication required', 401, 'AUTH_REQUIRED');
    this.name = 'AuthenticationError';
    Error.captureStackTrace(this, AuthenticationError);
}
AuthenticationError.prototype = Object.create(AppError.prototype);
AuthenticationError.prototype.constructor = AuthenticationError;

// ============================================================
// Structured Logger
// ============================================================

var logger = {
    _format: function(level, message, meta) {
        var entry = {
            level: level,
            message: message,
            timestamp: new Date().toISOString(),
            pid: process.pid,
            hostname: os.hostname()
        };
        if (meta) {
            Object.keys(meta).forEach(function(key) {
                entry[key] = meta[key];
            });
        }
        return JSON.stringify(entry);
    },
    info: function(message, meta) {
        console.log(logger._format('info', message, meta));
    },
    warn: function(message, meta) {
        console.warn(logger._format('warn', message, meta));
    },
    error: function(message, meta) {
        console.error(logger._format('error', message, meta));
    }
};

// ============================================================
// Async Handler Wrapper
// ============================================================

function asyncHandler(fn) {
    return function(req, res, next) {
        Promise.resolve(fn(req, res, next)).catch(next);
    };
}

// ============================================================
// Request ID Middleware
// ============================================================

var crypto = require('crypto');

function requestId(req, res, next) {
    req.requestId = req.headers['x-request-id'] || crypto.randomBytes(8).toString('hex');
    res.setHeader('X-Request-Id', req.requestId);
    next();
}

// ============================================================
// Simulated Database
// ============================================================

var users = {
    'usr_1': { id: 'usr_1', name: 'Alice Chen', email: '[email protected]' },
    'usr_2': { id: 'usr_2', name: 'Bob Park', email: '[email protected]' }
};

var db = {
    getUserById: function(id) {
        return new Promise(function(resolve) {
            setTimeout(function() {
                resolve(users[id] || null);
            }, 10);
        });
    },
    createUser: function(data) {
        return new Promise(function(resolve, reject) {
            setTimeout(function() {
                if (!data.email || !data.name) {
                    return reject(new Error('Missing required fields'));
                }
                var id = 'usr_' + Date.now();
                var user = { id: id, name: data.name, email: data.email };
                users[id] = user;
                resolve(user);
            }, 10);
        });
    },
    close: function() {
        return Promise.resolve();
    }
};

// ============================================================
// Express App
// ============================================================

var app = express();
app.use(express.json({ limit: '10kb' }));
app.use(requestId);

// Request logging
app.use(function(req, res, next) {
    var start = Date.now();
    res.on('finish', function() {
        logger.info('Request completed', {
            method: req.method,
            url: req.originalUrl,
            status: res.statusCode,
            duration: Date.now() - start,
            requestId: req.requestId
        });
    });
    next();
});

// ============================================================
// Routes
// ============================================================

app.get('/api/health', function(req, res) {
    res.json({ status: 'healthy', uptime: process.uptime(), timestamp: new Date().toISOString() });
});

app.get('/api/users/:id', asyncHandler(async function(req, res) {
    var user = await db.getUserById(req.params.id);
    if (!user) {
        throw new NotFoundError('User', req.params.id);
    }
    res.json({ data: user });
}));

app.post('/api/users', asyncHandler(async function(req, res) {
    var errors = {};
    if (!req.body.name) errors.name = 'Name is required';
    if (!req.body.email) errors.email = 'Email is required';
    if (req.body.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(req.body.email)) {
        errors.email = 'Invalid email format';
    }
    if (Object.keys(errors).length > 0) {
        throw new ValidationError('Invalid input', errors);
    }
    var user = await db.createUser(req.body);
    res.status(201).json({ data: user });
}));

// Simulates an unexpected programmer error
app.get('/api/crash', asyncHandler(async function(req, res) {
    var data = null;
    data.property; // TypeError -- programmer error
}));

// ============================================================
// 404 Handler
// ============================================================

app.use(function(req, res, next) {
    next(new NotFoundError('Route', req.method + ' ' + req.originalUrl));
});

// ============================================================
// Centralized Error Middleware
// ============================================================

function sanitizeBody(body) {
    if (!body) return null;
    var sanitized = Object.assign({}, body);
    var sensitiveFields = ['password', 'token', 'secret', 'apiKey', 'creditCard'];
    sensitiveFields.forEach(function(field) {
        if (sanitized[field]) sanitized[field] = '[REDACTED]';
    });
    return sanitized;
}

app.use(function(err, req, res, next) {
    var statusCode = err.statusCode || 500;
    var errorCode = err.errorCode || 'INTERNAL_ERROR';
    var isOperational = err.isOperational || false;

    // Log
    var logMeta = {
        errorCode: errorCode,
        errorMessage: err.message,
        method: req.method,
        url: req.originalUrl,
        requestId: req.requestId,
        ip: req.ip
    };

    if (statusCode >= 500) {
        logMeta.stack = err.stack;
        logMeta.body = sanitizeBody(req.body);
        logger.error('Server error', logMeta);
    } else {
        logger.warn('Client error', logMeta);
    }

    // Respond
    var response = {
        error: {
            code: errorCode,
            message: (statusCode === 500 && process.env.NODE_ENV === 'production')
                ? 'An internal error occurred'
                : err.message,
            requestId: req.requestId
        }
    };

    if (err.details) {
        response.error.details = err.details;
    }

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

    // If this is a programmer error, initiate shutdown
    if (!isOperational && statusCode === 500) {
        logger.error('Non-operational error detected. Initiating shutdown.', {
            requestId: req.requestId
        });
        gracefulShutdown('programmer-error');
    }
});

// ============================================================
// Server + Graceful Shutdown
// ============================================================

var PORT = process.env.PORT || 3000;
var server;
var connections = new Set();
var isShuttingDown = false;

function startServer() {
    server = http.createServer(app);

    server.on('connection', function(conn) {
        connections.add(conn);
        conn.on('close', function() {
            connections.delete(conn);
        });
    });

    server.listen(PORT, function() {
        logger.info('Server started', { port: PORT, nodeVersion: process.version, pid: process.pid });
    });
}

function gracefulShutdown(signal) {
    if (isShuttingDown) return;
    isShuttingDown = true;

    logger.info('Graceful shutdown initiated', { signal: signal });

    // Stop accepting new connections
    server.close(function() {
        logger.info('HTTP server closed');
        db.close()
            .then(function() {
                logger.info('Database connections closed. Exiting.');
                process.exit(0);
            })
            .catch(function(err) {
                logger.error('Error during resource cleanup', { error: err.message });
                process.exit(1);
            });
    });

    // Close idle keep-alive connections
    connections.forEach(function(conn) {
        conn.end();
    });

    // Force exit after timeout
    setTimeout(function() {
        logger.error('Shutdown timed out. Forcing exit.');
        process.exit(1);
    }, 30000);
}

// Process-level handlers
process.on('SIGTERM', function() { gracefulShutdown('SIGTERM'); });
process.on('SIGINT', function() { gracefulShutdown('SIGINT'); });

process.on('uncaughtException', function(err) {
    logger.error('Uncaught exception', { error: err.message, stack: err.stack });
    gracefulShutdown('uncaughtException');
});

process.on('unhandledRejection', function(reason) {
    logger.error('Unhandled rejection', { reason: String(reason), stack: reason && reason.stack });
    gracefulShutdown('unhandledRejection');
});

// Start
startServer();

Save this as app.js, install dependencies, and test it:

npm init -y
npm install express
node app.js

Test the endpoints:

# Health check
curl http://localhost:3000/api/health

# Get a user
curl http://localhost:3000/api/users/usr_1

# User not found
curl http://localhost:3000/api/users/usr_999

# Validation error
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": ""}'

# 404 route
curl http://localhost:3000/api/nonexistent

# Programmer error (triggers shutdown)
curl http://localhost:3000/api/crash

Expected responses:

// GET /api/users/usr_999
{
  "error": {
    "code": "NOT_FOUND",
    "message": "User not found: usr_999",
    "requestId": "a3f1b2c4d5e6f7a8",
    "details": {
      "resource": "User",
      "identifier": "usr_999"
    }
  }
}

// POST /api/users with invalid body
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid input",
    "requestId": "b4c2d3e5f6a7b8c9",
    "details": {
      "fields": {
        "name": "Name is required",
        "email": "Email is required"
      }
    }
  }
}

Common Issues and Troubleshooting

1. "Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client"

This happens when you accidentally send two responses for the same request. The most common cause: calling next(err) after already sending res.json(), or forgetting to return after sending a response.

// BUG: Missing return
app.get('/api/items/:id', function(req, res, next) {
    if (!req.params.id) {
        res.status(400).json({ error: 'ID required' });
        // Missing return! Execution continues.
    }
    // This runs even after the 400 response was sent
    db.getItem(req.params.id).then(function(item) {
        res.json(item); // CRASH: headers already sent
    });
});

// FIX: Always return after responding
app.get('/api/items/:id', function(req, res, next) {
    if (!req.params.id) {
        return res.status(400).json({ error: 'ID required' });
    }
    db.getItem(req.params.id).then(function(item) {
        res.json(item);
    });
});

2. Error middleware not being called -- errors disappear silently.

Express error middleware requires exactly four parameters. Even if you do not use next, you must include it:

// BROKEN: Only 3 parameters -- Express ignores this as error handler
app.use(function(err, req, res) {
    res.status(500).json({ error: err.message });
});

// FIXED: All 4 parameters present
app.use(function(err, req, res, next) {
    res.status(500).json({ error: err.message });
});

Also check that your error middleware is registered after all routes. If it is defined before your routes, it will never see errors thrown by those routes.

3. "UnhandledPromiseRejectionWarning: Error: ..." followed by process crash.

(node:12345) UnhandledPromiseRejectionWarning: Error: ECONNREFUSED
(node:12345) UnhandledPromiseRejectionWarning: Unhandled promise rejection.
This error originated either by throwing inside of an async function without
a catch block, or by rejecting a promise which was not handled with .catch().

This means an async route handler threw an error but you did not pass it to next(). The fix is to use the asyncHandler wrapper shown earlier, or manually add try/catch:

// BROKEN: async error never reaches Express
app.get('/api/data', async function(req, res) {
    var data = await riskyOperation(); // If this throws, Express never knows
    res.json(data);
});

// FIXED: Wrap with asyncHandler
app.get('/api/data', asyncHandler(async function(req, res) {
    var data = await riskyOperation();
    res.json(data);
}));

4. "TypeError: Cannot read properties of undefined (reading 'statusCode')" in error middleware.

This happens when something other than an Error object is thrown or passed to next(). Always throw proper Error instances, and add defensive checks in your middleware:

// Somewhere in your code
throw 'something went wrong'; // Throws a string, not an Error

// Your error middleware receives a string, not an object
app.use(function(err, req, res, next) {
    // err is the string 'something went wrong'
    // err.statusCode is undefined
    // err.message is undefined
    // err.stack is undefined

    // FIX: Normalize the error
    if (typeof err === 'string') {
        err = new Error(err);
    }
    var statusCode = err.statusCode || 500;
    res.status(statusCode).json({ error: { message: err.message || 'Unknown error' } });
});

5. Graceful shutdown hangs indefinitely.

If your server has keep-alive connections (the default in HTTP/1.1), server.close() waits for all connections to finish. Idle keep-alive connections can hold the server open for minutes. Always force-close idle connections:

// Track connections and close idle ones during shutdown
server.on('connection', function(conn) {
    connections.add(conn);
    conn.on('close', function() { connections.delete(conn); });
});

// During shutdown:
connections.forEach(function(conn) { conn.end(); });

Best Practices

  • Always use the asyncHandler wrapper for async routes. One missed try/catch and your promise rejection crashes the process. The wrapper eliminates this entire class of bugs.

  • Separate operational errors from programmer errors. Operational errors get a friendly response and a warning log. Programmer errors get logged with full stack traces and trigger a graceful shutdown. Never try to recover from a programmer error.

  • Never expose internal error details to clients in production. Stack traces, database query text, file paths, and internal error messages are gold for attackers. Return generic messages for 500 errors; include details only for 4xx validation errors.

  • Use structured JSON logging from day one. Switching from console.log('Error: ' + err.message) to structured logging after you already have 50 routes is painful. Start with structured logs and you can search, filter, and alert on any field.

  • Always include a request ID in error responses and logs. When a user reports "I got an error," the request ID is the only reliable way to find the corresponding log entry. Generate one if the client does not send one.

  • Set a shutdown timeout. Graceful shutdown should wait for in-flight requests but not forever. Thirty seconds is a reasonable default. After the timeout, force-exit so your process manager can restart the application.

  • Sanitize request bodies before logging. Passwords, tokens, and credit card numbers should never appear in your logs. Build a sanitizer and apply it consistently.

  • Add retry logic only for idempotent operations. Retrying a failed POST that creates a database record can result in duplicates. Retry GETs and idempotent PUTs; for non-idempotent operations, use idempotency keys instead.

  • Test your error handling paths. Write tests that trigger 404s, validation errors, database failures, and unexpected exceptions. If you have never tested your error middleware, you do not know if it works.


References

Powered by Contentful