Technology

Node.js Error Handling That Doesn't Crash Production

B

Boundev Team

Mar 23, 2026
15 min read
Node.js Error Handling That Doesn't Crash Production

A single unhandled promise rejection can crash your entire Node.js process. This guide covers async error handling patterns, custom error classes, graceful shutdown, and retry logic that keeps production applications running.

Key Takeaways

Synchronous try-catch works exactly as expected — async error handling requires an entirely different mental model
Express does not catch async errors automatically — every async route handler needs either try-catch or an asyncHandler wrapper
Separate operational errors (expected failures) from programmer errors (bugs) — only attempt recovery from the former
Custom error classes with typed handling transform cryptic error messages into actionable debugging signals
Global handlers (uncaughtException, unhandledRejection) are your last line of defense — not your primary error handling strategy

Picture this. It's 2 AM on a Tuesday. Your phone buzzes. The Node.js API that handles $50,000 in daily transactions just crashed. The cause? A single unhandled promise rejection from a database query that failed in an async function that nobody wrapped with try-catch. One missed error handler. $50,000 in missed revenue.

This is the reality of Node.js error handling. It's not that the language is bad at errors — it's that developers underestimate how different error propagation is in an event-driven, non-blocking architecture. At Boundev, we place senior Node.js engineers who understand that error handling isn't an afterthought — it's the foundation of production reliability. This guide covers the patterns that separate apps that crash at 2 AM from apps that degrade gracefully.

The Fundamental Problem: Why Node.js Error Handling Confuses Developers

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.

The core confusion stems from how JavaScript's event loop handles errors. In synchronous code, when an exception is thrown, JavaScript creates an error object and unwinds the call stack until it finds a try-catch block. This works exactly as you'd expect. In asynchronous code, the error propagates differently. When a promise rejects or a callback receives an error, the handling depends on how the async operation was initiated — and if you don't explicitly handle it, the error can crash your entire process.

According to research from Grizzly Peak Software, the difference between a Node.js application that crashes at 3 AM and one that degrades gracefully comes down to custom error hierarchies, centralized handling, graceful shutdown, retry logic, and structured logging. Most tutorials cover try/catch and call it done — but production systems need all of it.

Synchronous vs. Asynchronous Error Patterns

Understanding the difference between synchronous and asynchronous errors is foundational to everything that follows. These are two fundamentally different error propagation models, and conflating them is where most error handling bugs originate.

Synchronous Errors

Synchronous errors are straightforward. When a function throws an exception during execution, the error propagates up the call stack until a try-catch block catches it. The error happens, the stack unwinds, the catch block runs. Simple.

javascript
try {
  const data = JSON.parse(userInput);
  console.log('Parsed successfully');
} catch (error) {
  console.error('Failed to parse:', error.message);
}

This pattern works perfectly for synchronous code. The try block executes, and if an exception occurs, the catch block handles it. The key requirement is that the code inside try executes synchronously — no callbacks, no promises, no async operations.

Asynchronous Errors

Asynchronous errors are where it gets tricky. When you use callbacks, promises, or async/await, the error might occur in a different tick of the event loop. By the time the error is thrown, the try-catch frame from the original call is long gone.

javascript
try {
  setTimeout(() => {
    throw new Error('This will crash');
  }, 1000);
  console.log('This runs first');
} catch (error) {
  // This catch block will NEVER run
  console.error('Caught the error');
}

The setTimeout callback runs in a different tick of the event loop. By the time it throws, the try-catch frame is closed. The error propagates to the global handler, and if you haven't set one up, your process terminates.

The Three Patterns You Need for Async Error Handling

Async/await made asynchronous code look synchronous, which was a huge improvement for readability. But it also made it easier to forget that await is still asynchronous under the hood. Here are the three patterns that cover every async error handling scenario.

Pattern 1: Try-Catch with Async/Await

For individual async operations, try-catch works exactly as you'd expect:

javascript
async function fetchUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    const user = await response.json();
    return user;
  } catch (error) {
    console.error('Failed to fetch user:', error.message);
    return null;
  }
}

Pattern 2: The asyncHandler Wrapper for Express Routes

Here's where most Node.js developers hit a wall. Express does not catch async errors automatically. An async route handler that throws will crash your application — Express simply doesn't know how to handle the rejected promise.

javascript
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  res.json(user);
}));

The asyncHandler wrapper catches any rejected promise from the route handler and passes the error to Express's error middleware via next(). This eliminates an entire class of bugs. One missed try-catch in an async Express route and your process crashes. Use the wrapper everywhere.

Pattern 3: Promise.allSettled for Parallel Operations

When you run multiple async operations in parallel, Promise.all fails fast — if any promise rejects, the entire thing fails. Promise.allSettled waits for all promises to resolve or reject and gives you the full picture:

javascript
async function fetchDashboardData(userId) {
  const results = await Promise.allSettled([
    fetchUser(userId),
    fetchOrders(userId),
    fetchNotifications(userId)
  ]);
  
  const [userResult, ordersResult, notificationsResult] = results;
  
  return {
    user: userResult.status === 'fulfilled' ? userResult.value : null,
    orders: ordersResult.status === 'fulfilled' ? ordersResult.value : [],
    notifications: notificationsResult.status === 'fulfilled' ? notificationsResult.value : []
  };
}

Need Node.js engineers who know error handling inside out?

Boundev's staff augmentation team places senior Node.js developers with proven production experience — engineers who understand async error propagation, custom error hierarchies, and graceful degradation.

See How We Do It

Custom Error Classes: From Cryptic Messages to Actionable Errors

The default JavaScript Error object is intentionally minimal. It gives you a message and a stack trace — useful for debugging, but not useful for programmatic error handling. When an error occurs, you need to know what kind of error it is so you can decide what to do.

Custom error classes solve this. They let you create typed errors that carry additional context and enable programmatic handling.

javascript
class AppError extends Error {
  constructor(message, statusCode, code) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

class ValidationError extends AppError {
  constructor(message, fields) {
    super(message, 400, 'VALIDATION_ERROR');
    this.fields = fields;
  }
}

class NotFoundError extends AppError {
  constructor(resource) {
    super(`${resource} not found`, 404, 'NOT_FOUND');
    this.resource = resource;
  }
}

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

With these classes, your error handler can make decisions based on error type:

javascript
app.use((error, req, res, next) => {
  if (error instanceof ValidationError) {
    return res.status(400).json({
      error: error.message,
      code: error.code,
      fields: error.fields
    });
  }
  
  if (error instanceof NotFoundError) {
    return res.status(404).json({
      error: error.message,
      code: error.code
    });
  }
  
  if (error instanceof DatabaseError) {
    console.error('Database error:', error.originalError);
    return res.status(500).json({
      error: 'Database operation failed',
      code: error.code
    });
  }
  
  // Generic error for unknown error types
  console.error('Unexpected error:', error);
  res.status(500).json({
    error: process.env.NODE_ENV === 'production' 
      ? 'Internal server error' 
      : error.message
  });
});

Global Error Handlers: Your Last Line of Defense

Process-level error handlers catch errors that escape all other handling. They're not your primary error handling strategy — they're your safety net when everything else fails.

Uncaught Exceptions

javascript
process.on('uncaughtException', (error) => {
  console.error('Uncaught Exception:', error);
  errorTracker.capture(error);
  fs.appendFileSync('crash.log', `${new Date().toISOString()} - ${error.stack}\n`);
  process.exit(1);
});

Uncaught exceptions represent bugs — something your code didn't anticipate. The correct response is to log the error, alert your team, and exit. Never attempt to recover from an uncaught exception. Your application state may be corrupted, and continuing could cause data loss or inconsistent state.

Unhandled Promise Rejections

javascript
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  errorTracker.capture(reason);
});

Graceful Shutdown: The Pattern That Saves Your Users

When your application needs to stop — whether due to an uncaught exception, a deploy, or a scaling event — graceful shutdown ensures in-flight requests complete and connections close cleanly.

javascript
const server = app.listen(3000);
const connections = new Set();

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

const shutdown = (signal) => {
  console.log(`Received ${signal}. Starting graceful shutdown...`);
  
  server.close(() => {
    console.log('HTTP server closed');
    process.exit(0);
  });
  
  setTimeout(() => {
    console.error('Could not close connections in time');
    process.exit(1);
  }, 30000);
  
  for (const conn of connections) {
    conn.end();
  }
};

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Retry Logic: For Transient Failures Only

Not all errors should be retried. Network timeouts, temporary database unavailability, and rate limit responses are transient — they might succeed on the second attempt. Validation errors, authentication failures, and server errors with no retry-after header are not transient — retrying them wastes resources and delays error reporting.

javascript
async function retry(fn, options = {}) {
  const { attempts = 3, delay = 1000, shouldRetry = () => true } = options;
  let lastError;
  
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      if (i === attempts - 1 || !shouldRetry(error)) {
        throw error;
      }
      await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, i)));
    }
  }
  throw lastError;
}

Building Node.js Applications That Handle Errors Gracefully?

Partner with Boundev to access senior Node.js engineers who understand async error propagation, custom error hierarchies, and production-ready reliability patterns.

Talk to Our Team

Structured Logging: Making Errors Searchable

Console.error outputs to stdout. It's useful for development but nearly useless in production. When a user reports "I got an error on the checkout page," you need to find that error in thousands of log lines from hundreds of requests.

Structured logging solves this. Every log entry is a JSON object with consistent fields — timestamp, level, message, request ID, user ID, and metadata. This makes logs searchable, filterable, and parseable by log aggregation tools.

javascript
const logger = {
  error: (message, error, meta = {}) => {
    console.error(JSON.stringify({ 
      timestamp: new Date().toISOString(), 
      level: 'error', 
      message, 
      error: { message: error.message, stack: error.stack },
      ...meta 
    }));
  }
};

app.use((error, req, res, next) => {
  logger.error('Request failed', error, {
    requestId: req.headers['x-request-id'],
    path: req.path,
    userId: req.user?.id
  });
  res.status(500).json({ error: 'Internal server error' });
});

How Boundev Solves This for You

Everything we've covered in this guide — async error handling patterns, custom error hierarchies, graceful shutdown, retry logic, and structured logging — is exactly what our Node.js teams build from day one. Here's how we approach production-ready error handling for our clients.

We build you a full remote engineering team with senior Node.js specialists — architects who design error handling infrastructure from the start.

● Custom error hierarchies and centralized error handling
● Structured logging and observability setup

Add Node.js developers with proven error handling experience to your existing team — engineers who ship production-ready code from week one.

● AsyncHandler patterns and Express error middleware
● Retry logic and graceful shutdown implementation

Hand us a Node.js application with reliability issues. We audit the error handling, fix the patterns, and deliver production-ready code.

● Error handling audit and remediation
● Observability and monitoring infrastructure

The Bottom Line

Error handling in Node.js is not optional. It's the difference between an application that fails silently and loses data and one that degrades gracefully and recovers automatically.

The Bottom Line

3
Async error handling patterns you need
1
asyncHandler wrapper per Express app
30s
Maximum graceful shutdown timeout
2
Error categories: operational vs programmer

The patterns that matter: asyncHandler for every Express route, custom error classes for typed handling, structured logging from day one, and graceful shutdown for every deployed service. These aren't advanced patterns — they're the baseline for production Node.js.

The application that crashed at 2 AM had one missing asyncHandler. One. That's all it took. Your application deserves better engineering.

Need help implementing production-ready error handling?

Boundev's Node.js teams build error handling infrastructure that scales — custom error classes, centralized handlers, graceful shutdown, and structured logging from day one.

See How We Do It

Frequently Asked Questions

What's the difference between synchronous and asynchronous error handling in Node.js?

Synchronous errors use standard try-catch — when an exception is thrown, JavaScript unwinds the call stack until it finds a catch block. Asynchronous errors propagate differently because they occur in a different tick of the event loop. Callback errors are passed as the first argument, promise rejections need .catch() or try-catch with async/await, and unhandled async errors can crash your process. Understanding this difference is foundational to writing reliable Node.js code.

Why does Express crash when async route handlers throw?

Express's error handling middleware only catches synchronous errors thrown in route handlers. Async route handlers return promises — when those promises reject, Express doesn't know about it. The rejection propagates to the global handlers, and if unhandled, crashes your process. The solution is the asyncHandler wrapper: a function that catches rejected promises and passes them to next() so Express's error middleware can handle them.

What are operational errors vs programmer errors in Node.js?

Operational errors are expected failures: invalid user input, missing files, network timeouts, database unavailable. These are recoverable and should return friendly error messages to users. Programmer errors are bugs: null references, incorrect types, logic mistakes. These indicate code that needs to be fixed. Never attempt to recover from programmer errors — log them, alert your team, and exit. Confusing the two leads to either crashed apps or silent failures that hide bugs.

How do you implement graceful shutdown in Node.js?

Graceful shutdown handles SIGTERM and SIGINT signals. Stop accepting new connections, wait for in-flight requests to complete (with a timeout), close existing connections, and exit cleanly. Use a 30-second timeout — if connections don't close in time, force exit. This pattern ensures zero-downtime deploys on Kubernetes and prevents users from seeing errors when you restart or scale down services.

When should you retry failed operations vs fail immediately?

Retry transient failures: network timeouts, temporary service unavailability, rate limit responses (with backoff). Fail immediately for non-transient errors: validation errors, authentication failures, 404 responses, 5xx errors with no retry-after header. Use exponential backoff (1s, 2s, 4s) to avoid overwhelming struggling services. Only retry idempotent operations — retrying a POST that creates a record can result in duplicates.

Free Consultation

Let's Build Node.js Applications That Don't Crash

You now know exactly what production-ready error handling looks like. The next step is having the team that can build it.

200+ companies have trusted us to build their engineering teams. Tell us what you need — we'll respond within 24 hours.

200+
Companies Served
72hrs
Avg. Team Deployment
98%
Client Satisfaction

Tags

#Node.js#Error Handling#JavaScript#Express#Backend Development#Staff Augmentation
B

Boundev Team

At Boundev, we're passionate about technology and innovation. Our team of experts shares insights on the latest trends in AI, software development, and digital transformation.

Ready to Transform Your Business?

Let Boundev help you leverage cutting-edge technology to drive growth and innovation.

Get in Touch

Start Your Journey Today

Share your requirements and we'll connect you with the perfect developer within 48 hours.

Get in Touch