If you’ve ever chased a vague “Something went wrong” in production at 2 a.m., you know why error handling matters. The goal isn’t to stop every failure (you can’t) but to make failures boring: predictable, contained, and recoverable. With Node.js 24.x improving async context performance and stability, now’s a great time to tighten your approach. This guide is a practical playbook; less theory, more patterns you can ship.

Why error handling matters in production

Things will fail: APIs time out, inputs go sideways, databases hiccup. Good error handling turns chaos into clear signals and graceful fallbacks:

  • Application stays up (or shuts down safely when it must)
  • Users get helpful messages, not stack traces
  • Data remains consistent
  • Security posture improves (no leaking internals)
  • Incidents are debuggable with context and breadcrumbs

Understanding error types in Node.js

Synchronous vs. asynchronous errors

Synchronous throws bubble immediately; async errors surface on await or promise rejection. Treat both uniformly in your handlers.

See also: A Deep Dive into the Node.js Event Loop and Node.js Timers vs process.nextTick() for how async work is scheduled and why errors surface when they do.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Synchronous error
function validateUser(user) {
    if (!user.email) {
        throw new Error('Email is required'); // Synchronous throw
    }
}

// Asynchronous error
async function fetchUserData(userId) {
    const user = await User.findById(userId);
    if (!user) {
        throw new Error('User not found'); // Asynchronous throw
    }
    return user;
}

Operational vs. programmer errors

Operational errors are expected (timeouts, missing files). Programmer errors are bugs (null deref, wrong types). Handle the former, fix the latter.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Operational error (expected, should be handled gracefully)
function readConfigFile(path) {
    try {
        return fs.readFileSync(path, 'utf8');
    } catch (error) {
        if (error.code === 'ENOENT') {
            // File doesn't exist - operational error
            return createDefaultConfig();
        }
        throw error; // Re-throw unexpected errors
    }
}

// Programmer error (bugs in code)
function calculateDiscount(price, discount) {
    // Programmer forgot to validate input
    return price - (price * discount); // Could return NaN if invalid inputs
}

Modern patterns that work

Async/await with try/catch and selective recovery

Only catch where you can add value: translate, enrich, or recover. Otherwise, let it propagate to your global handler.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class UserService {
    async createUser(userData) {
        try {
            const validatedData = this.validateUserData(userData);
            const user = await UserModel.create(validatedData);
            
            // Send welcome email (might fail)
            await this.sendWelcomeEmail(user.email);
            
            return user;
        } catch (error) {
            // Handle specific error types
            if (error.name === 'ValidationError') {
                throw new AppError('Invalid user data', 400);
            }
            
            if (error.code === 'EMAIL_FAILED') {
                // Log but don't fail the user creation
                logger.warn('Welcome email failed', { userId: user.id, error });
            }
            
            throw new AppError('User creation failed', 500);
        }
    }
    
    async sendWelcomeEmail(email) {
        try {
            await emailService.send({
                to: email,
                subject: 'Welcome!',
                template: 'welcome'
            });
        } catch (error) {
            // Wrap the error with more context
            const emailError = new Error('Welcome email delivery failed');
            emailError.code = 'EMAIL_FAILED';
            emailError.originalError = error;
            throw emailError;
        }
    }
}

Related: When coordinating timeouts/cancellations across async boundaries, use [AbortController and AbortSignal in Node.js](/nodejs/nodejs-async-operations-abortcontroller-abortsignal/).

Promise-based flows without cascading failures

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function processBatch(data) {
    return Promise.allSettled(
        data.map(item => 
            apiCall(item)
                .then(processResult)
                .catch(error => {
                    // Handle individual item failure without breaking the batch
                    logger.error('Item processing failed', { item, error });
                    return { status: 'failed', error: error.message };
                })
        )
    );
}

Custom error classes keep things tidy

Give your errors structure: HTTP-ish status, operational flag, and optional details. That’s enough for consistent responses and logs.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class AppError extends Error {
    constructor(message, statusCode, isOperational = true) {
        super(message);
        this.statusCode = statusCode;
        this.isOperational = isOperational;
        this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
        this.timestamp = new Date().toISOString();
        
        Error.captureStackTrace(this, this.constructor);
    }
}

class ValidationError extends AppError {
    constructor(message, details = []) {
        super(message, 400);
        this.name = 'ValidationError';
        this.details = details;
    }
}

class DatabaseError extends AppError {
    constructor(message, originalError) {
        super(message, 503);
        this.name = 'DatabaseError';
        this.originalError = originalError;
    }
}

// Usage
function createProduct(productData) {
    if (!productData.name) {
        throw new ValidationError('Product name is required', [
            { field: 'name', message: 'Name is required' }
        ]);
    }
    
    try {
        return db.products.create(productData);
    } catch (error) {
        if (error.code === 'SQLITE_CONSTRAINT') {
            throw new DatabaseError('Database constraint violation', error);
        }
        throw error;
    }
}

Global error handling middleware

Express.js example

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// errorHandler.js
function errorHandler(err, req, res, next) {
    // Log error
    logger.error('Unhandled error', {
        message: err.message,
        stack: err.stack,
        url: req.url,
        method: req.method,
        ip: req.ip,
        userAgent: req.get('User-Agent')
    });
    
    // Check if response already sent
    if (res.headersSent) {
        return next(err);
    }
    
    // Development vs Production error response
    if (process.env.NODE_ENV === 'development') {
        return res.status(err.statusCode || 500).json({
            error: {
                message: err.message,
                stack: err.stack,
                statusCode: err.statusCode,
                timestamp: err.timestamp
            }
        });
    }
    
    // Production error response (don't expose stack traces)
    if (err.isOperational) {
        return res.status(err.statusCode).json({
            error: {
                message: err.message,
                statusCode: err.statusCode,
                timestamp: err.timestamp
            }
        });
    }
    
    // Unknown errors in production
    res.status(500).json({
        error: {
            message: 'Something went wrong!',
            statusCode: 500,
            timestamp: new Date().toISOString()
        }
    });
}

// app.js
const express = require('express');
const app = express();

// Routes
app.use('/api', require('./routes'));

// 404 handler
app.use('*', (req, res) => {
    throw new AppError(`Route ${req.originalUrl} not found`, 404);
});

// Global error handler (must be last)
app.use(errorHandler);

Handling uncaught exceptions and unhandled rejections

Crash fast on programmer errors; shut down gracefully on fatal states. Don’t keep a poisoned process alive.

For Kubernetes-style probes and graceful shutdown patterns, see Health Checks and Graceful Shutdown of Express.js with Lightship.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// processHandlers.js
class ProcessHandlers {
    static initialize() {
        // Uncaught synchronous exceptions
        process.on('uncaughtException', (error) => {
            logger.error('UNCAUGHT EXCEPTION! 💥 Shutting down...', error);
            // Close server and exit process
            server.close(() => {
                process.exit(1);
            });
            
            // Force exit after timeout
            setTimeout(() => {
                process.abort(); // Generate core dump
            }, 1000).unref();
        });
        
        // Unhandled promise rejections
        process.on('unhandledRejection', (reason, promise) => {
            logger.error('UNHANDLED REJECTION! 💥 Shutting down...', { reason, promise });
            // Close server and exit process
            server.close(() => {
                process.exit(1);
            });
        });
        
        // SIGTERM graceful shutdown
        process.on('SIGTERM', () => {
            logger.info('SIGTERM received');
            server.close(() => {
                logger.info('Process terminated');
            });
        });
    }
}

module.exports = ProcessHandlers;

Smarter context with AsyncLocalStorage (Node.js 24.x)

Node.js 24.x makes async context propagation cheaper and more reliable. Attach request IDs, user IDs, and breadcrumbs once; read them anywhere.

Pair this with end-to-end telemetry; see Monitoring Node.js Applications in Production.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
const async_hooks = require('async_hooks');
const fs = require('fs');

class RequestContext {
    static storage = new async_hooks.AsyncLocalStorage();
    
    static run(context, callback) {
        this.storage.run(context, callback);
    }
    
    static get() {
        return this.storage.getStore();
    }
}

// Enhanced error handler with context
function contextualErrorHandler(error) {
    const context = RequestContext.get();
    
    logger.error('Request error', {
        message: error.message,
        stack: error.stack,
        requestId: context?.requestId,
        userId: context?.userId,
        url: context?.url
    });
    
    throw error;
}

// Usage in middleware
app.use((req, res, next) => {
    const context = {
        requestId: generateRequestId(),
        userId: req.user?.id,
        url: req.url,
        method: req.method
    };
    
    RequestContext.run(context, () => {
        next();
    });
});

// In your services
async function getUserProfile(userId) {
    try {
        const user = await User.findById(userId);
        if (!user) {
            throw new AppError('User not found', 404);
        }
        return user;
    } catch (error) {
        contextualErrorHandler(error);
    }
}

Production monitoring and logging

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// logger.js
const winston = require('winston');

const logger = winston.createLogger({
    level: 'info',
    format: winston.format.combine(
        winston.format.timestamp(),
        winston.format.errors({ stack: true }),
        winston.format.json()
    ),
    defaultMeta: { service: 'user-service' },
    transports: [
        new winston.transports.File({ 
            filename: 'logs/error.log', 
            level: 'error',
            handleExceptions: true 
        }),
        new winston.transports.File({ 
            filename: 'logs/combined.log' 
        })
    ]
});

if (process.env.NODE_ENV !== 'production') {
    logger.add(new winston.transports.Console({
        format: winston.format.simple()
    }));
}

// Error tracking integration
class ErrorTracker {
    static track(error, context = {}) {
        logger.error(error.message, { 
            error: error.stack, 
            ...context 
        });
        
        // Send to external service (Sentry, DataDog, etc.)
        if (process.env.SENTRY_DSN) {
            Sentry.captureException(error, { extra: context });
        }
    }
}

module.exports = { logger, ErrorTracker };

Deep dive: Monitoring Node.js Applications.

Putting it together: a small e‑commerce flow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
class OrderService {
    async createOrder(orderData, userId) {
        const context = RequestContext.get();
        
        try {
            // Validate order data
            this.validateOrderData(orderData);
            
            // Check inventory
            await this.checkInventory(orderData.items);
            
            // Process payment
            const payment = await this.processPayment(orderData.payment);
            
            // Create order
            const order = await Order.create({
                ...orderData,
                userId,
                paymentId: payment.id,
                status: 'confirmed'
            });
            
            // Update inventory
            await this.updateInventory(orderData.items);
            
            // Send confirmation
            await this.sendOrderConfirmation(userId, order);
            
            return order;
            
        } catch (error) {
            // Contextual error handling
            ErrorTracker.track(error, {
                userId,
                orderData,
                requestId: context?.requestId
            });
            
            // Handle specific business logic errors
            if (error instanceof InventoryError) {
                throw new AppError('Some items are out of stock', 400);
            }
            
            if (error instanceof PaymentError) {
                // Rollback any partial operations
                await this.rollbackOrderCreation(orderData);
                throw new AppError('Payment processing failed', 402);
            }
            
            // Re-throw as operational error
            throw new AppError('Order creation failed', 500);
        }
    }
    
    async checkInventory(items) {
        for (const item of items) {
            const stock = await Inventory.get(item.productId);
            if (stock.quantity < item.quantity) {
                throw new InventoryError(`Insufficient stock for product ${item.productId}`);
            }
        }
    }
}

Testing your error handling

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// errorHandling.test.js
const request = require('supertest');
const app = require('../app');

describe('Error Handling', () => {
    it('should return 404 for unknown routes', async () => {
        const response = await request(app)
            .get('/unknown-route')
            .expect(404);
        
        expect(response.body.error.message).toBe('Route /unknown-route not found');
    });
    
    it('should handle validation errors gracefully', async () => {
        const response = await request(app)
            .post('/api/users')
            .send({}) // Empty payload
            .expect(400);
        
        expect(response.body.error.message).toContain('Validation failed');
    });
    
    it('should handle database errors', async () => {
        // Mock database failure
        jest.spyOn(UserModel, 'create').mockRejectedValue(
            new DatabaseError('Connection failed')
        );
        
        const response = await request(app)
            .post('/api/users')
            .send({ email: 'test@example.com', name: 'Test User' })
            .expect(503);
        
        expect(response.body.error.message).toBe('Service temporarily unavailable');
    });
});

Best practices (pinned)

  • Use custom error classes for different error types
  • Catch locally only when you add value; otherwise bubble to the global handler
  • Don’t expose internals or stack traces to clients
  • Centralize responses in a single error middleware
  • Treat unhandled rejections and exceptions as fatal; exit gracefully
  • Use structured logs with request/user context
  • Test unhappy paths the same way you test happy paths
  • Monitor error rates, saturation, and retry storms
  • Use async context tracking for correlation
  • Implement graceful shutdown procedures

Quick production checklist

  • Error classes defined (AppError, ValidationError, etc.)
  • Global error middleware registered last
  • Process handlers for uncaughtException, unhandledRejection, SIGTERM
  • Request ID (and user ID if available) injected via AsyncLocalStorage
  • Structured logger with JSON output in prod
  • Health/readiness endpoints fail when dependencies are down
  • Timeouts and retries set for all outbound calls
  • Redaction in logs for secrets/PII
  • Load tests include failure scenarios (timeouts, 500s, partial outages)

Common pitfalls to avoid

  • Swallowing errors without logging or rethrowing
  • Returning 200 with an error payload (use proper status codes)
  • Mixing programmer and operational errors in the same handler
  • Leaking stack traces and SQL messages in production responses
  • Retrying non‑idempotent operations without safeguards
  • Keeping the process alive after fatal errors

Conclusion

Effective error handling in Node.js 24.x is a deliberate, layered design: classify errors, isolate failure domains, use async-safe patterns, instrument richly, and fail gracefully. Treat every boundary; API, worker, queue, and process; as a containment line with clear recovery paths and actionable logs and metrics. Apply these practices consistently and your services will degrade predictably under stress, be simpler to debug, and earn your users’ trust.