Node.js
June 10, 20266 min read...
Node.jsJune 10, 20266 min read

Node.js Error Handling Patterns That Save Production Debugging

Poor error handling is the #1 cause of production outages. This guide covers patterns for synchronous errors, promise rejections, uncaught exceptions, and building resilient APIs with proper error responses.

Node.js Error Handling Patterns That Save Production Debugging

Errors are inevitable. How you handle them separates production-grade code from prototypes. Node.js has unique error handling challenges: asynchronous operations, event emitters, and the fact that unhandled rejections can crash your process. This guide provides battle-tested patterns from high-scale applications.

The Three Types of Node.js Errors

Understanding error types is the first step:

  • Operational errors: Failed database connections, invalid user input, network timeouts — predictable and recoverable.
  • Programmer errors: Null reference, type mismatch — bugs that should crash and be fixed in code.
  • System errors: Out of memory, file descriptor limit — require process restart.

Pattern #1: Centralized Error Handler Middleware (Express/Fastify)

// Custom error classes
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
  }
}

class ValidationError extends AppError { constructor(message) { super(message, 400); this.name = ‘ValidationError’; } }

// Async wrapper to avoid try-catch in every route const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);

// Route using asyncHandler app.get(‘/users/:id’, asyncHandler(async (req, res) => { const user = await db.findUser(req.params.id); if (!user) throw new AppError(‘User not found’, 404); res.json(user); }));

// Central error handler (must be last middleware) app.use((err, req, res, next) => { const { statusCode = 500, message, isOperational } = err;

// Log full error internally console.error(err);

// Send safe response to client res.status(statusCode).json({ error: isOperational ? message : ‘Internal server error’, …(process.env.NODE_ENV === ‘development’ && { stack: err.stack }) }); });

Pattern #2: Handling Async/Await Errors Without Try-Catch Spam

Use a Go-style error handling utility:

function to(promise) {
  return promise
    .then(data => [null, data])
    .catch(err => [err, null]);
}

// Usage const [err, user] = await to(db.findUser(id)); if (err) { if (err.code === ‘NOT_FOUND’) { return res.status(404).json({ error: ‘User not found’ }); } throw err; // Let global handler catch }

Pattern #3: Graceful Shutdown on Fatal Errors

Handle uncaughtException and unhandledRejection without letting the process hang:

process.on('uncaughtException', (err) => {
  console.error('Uncaught Exception:', err);
  // Perform cleanup (close servers, write logs)
  gracefulShutdown(1);
});

process.on(‘unhandledRejection’, (reason, promise) => { console.error(‘Unhandled Rejection at:’, promise, ‘reason:’, reason); gracefulShutdown(1); });

function gracefulShutdown(exitCode) { server.close(() => { console.log(‘HTTP server closed’); process.exit(exitCode); });

// Force exit after 10 seconds setTimeout(() => process.exit(exitCode), 10000); }

// You can also attach to signals process.on(‘SIGTERM’, () => gracefulShutdown(0));

Pattern #4: Error Handling in Worker Threads

Worker threads don't share memory — errors must be propagated via message passing:

// Worker code
import { parentPort } from 'worker_threads';
try {
  const result = riskyOperation();
  parentPort.postMessage({ success: true, data: result });
} catch (err) {
  parentPort.postMessage({ success: false, error: err.message });
}

// Main code const worker = new Worker(‘./worker.js’); worker.on(‘message’, (msg) => { if (!msg.success) { console.error(‘Worker error:’, msg.error); // Decide to restart worker or fail operation } }); worker.on(‘error’, (err) => { console.error(‘Worker thread error:’, err); });

Pattern #5: Retry and Backoff for Transient Errors

Database connection drops, rate limits, and network blips deserve retries:

async function retryOperation(fn, maxRetries = 3, baseDelay = 100) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (err) {
      const isTransient = [
        'ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', '429'
      ].some(code => err.code === code || err.statusCode === code);
  if (!isTransient || i === maxRetries - 1) throw err;
  
  const delay = baseDelay * Math.pow(2, i) + Math.random() * 100;
  await new Promise(resolve => setTimeout(resolve, delay));
}

} }

// Usage const data = await retryOperation(() => fetchFromUnreliableAPI());

Pattern #6: Structured Error Logging

Always log errors with context for debugging. Use a logger like Pino:

import pino from 'pino';
const logger = pino({ level: 'info' });

try { await processPayment(orderId); } catch (err) { logger.error({ err: { message: err.message, stack: err.stack, code: err.code }, orderId, userId: req.user.id, timestamp: new Date().toISOString() }, ‘Payment processing failed’); throw new AppError(‘Payment failed’, 500); }

Common Anti-Patterns to Avoid

  • Empty catch blocks: catch(err) {} swallows errors silently. Always log or rethrow.
  • Throwing strings: Always throw Error objects (or subclasses) to get stack traces.
  • Ignoring promise rejections: Always add .catch() or use await inside async functions.
  • Overly broad catch: Catching all errors and sending 500 prevents proper HTTP status codes.

Production Monitoring: Sentry or Rollbar Integration

import * as Sentry from '@sentry/node';

Sentry.init({ dsn: process.env.SENTRY_DSN });

// In error handler app.use((err, req, res, next) => { Sentry.captureException(err, { extra: { userId: req.user?.id, url: req.url } }); // … rest of error handler });

Conclusion

Implement these patterns today: centralized error handling middleware, async wrapper to avoid repetitive try-catch, graceful shutdown for fatal errors, and structured logging with context. Your production debugging time will drop by 70%. Remember: operational errors are expected; programmer errors should crash and be fixed. Build with resilience from day one.

Comments

Join the conversation — sign in to leave a comment.