🟢 Node.js Q102 / 118

How do you handle errors in a Node.js application?

AI-Powered Answer ✓ Answered

Error handling is a critical aspect of building stable and reliable Node.js applications. Node.js's asynchronous, event-driven nature introduces unique challenges, requiring distinct strategies for synchronous and asynchronous errors. This guide outlines common patterns and best practices for managing errors effectively.

1. Synchronous Error Handling (`try...catch`)

For synchronous code, standard JavaScript try...catch blocks are used to intercept exceptions thrown during execution. This is effective for code that runs immediately and completes without involving asynchronous operations.

javascript
try {
  const result = someSynchronousFunction();
  console.log(result);
} catch (error) {
  console.error("Synchronous error caught:", error.message);
  // Handle the error, e.g., log it, send a response
}

2. Asynchronous Error Handling

2.1. Callback-based Errors (Error-First Callbacks)

In older Node.js APIs and callback-style functions, errors are typically passed as the first argument to the callback function. If an error occurs, the first argument is an Error object; otherwise, it's null or undefined. Always check for an error first.

javascript
const fs = require('fs');

fs.readFile('/path/to/file.txt', 'utf8', (err, data) => {
  if (err) {
    console.error("File read error:", err);
    return; // Important to return after handling
  }
  console.log("File content:", data);
});

2.2. Promises (`.catch()`)

Promises provide a cleaner way to handle asynchronous operations and their errors. The .catch() method is used to register a callback that gets invoked when the promise is rejected. It catches errors from all preceding .then() handlers in the chain.

javascript
function fetchData() {
  return new Promise((resolve, reject) => {
    // Simulate an async operation
    setTimeout(() => {
      const success = Math.random() > 0.5;
      if (success) {
        resolve("Data fetched successfully!");
      } else {
        reject(new Error("Failed to fetch data."));
      }
    }, 1000);
  });
}

fetchData()
  .then(data => console.log(data))
  .catch(error => console.error("Promise error caught:", error.message));

2.3. Async/Await (`try...catch`)

async/await allows writing asynchronous code that looks and behaves more like synchronous code. This makes standard try...catch blocks effective for handling errors from await expressions, as rejected promises are re-thrown as exceptions.

javascript
async function processData() {
  try {
    const data = await fetchData(); // Assuming fetchData returns a Promise
    console.log("Processed data:", data);
  } catch (error) {
    console.error("Async/await error caught:", error.message);
  }
}
processData();

3. Global/Process-Level Error Handling

These handlers are last resorts for catching errors that were not handled elsewhere. While useful, relying solely on them is generally discouraged as they indicate unhandled exceptions and can lead to an unstable application state. It's best practice to log the error and then gracefully shut down the application.

3.1. `process.on('uncaughtException')`

Catches synchronous errors that were not caught by any try...catch block. It's generally advised to log the error and then gracefully shut down the application, as the process might be in an inconsistent state after an uncaught exception.

javascript
process.on('uncaughtException', (err) => {
  console.error("UNCAUGHT EXCEPTION! Shutting down...");
  console.error(err.name, err.message, err.stack);
  // Perform synchronous cleanup if necessary (e.g., close DB connections)
  process.exit(1); // Exit with a failure code
});

// Example (uncomment to test): 
// throw new Error("This is an uncaught synchronous error!");

3.2. `process.on('unhandledRejection')`

Catches Promise rejections that were not handled with a .catch() block. Similar to uncaughtException, it's best to log and consider a graceful shutdown, as unhandled rejections often indicate programming errors.

javascript
process.on('unhandledRejection', (reason, promise) => {
  console.error("UNHANDLED REJECTION! Shutting down...");
  console.error("Reason:", reason);
  console.error("Promise:", promise);
  // Log the error, perform cleanup, and exit
  process.exit(1);
});

// Example (uncomment to test): 
// Promise.reject(new Error("This is an unhandled promise rejection!"));

4. Express.js Error Handling Middleware

In Express.js applications, errors can be handled by special error-handling middleware functions that take four arguments: (err, req, res, next). These are typically defined after all other routes and middleware to ensure they catch errors from the entire request pipeline.

javascript
const express = require('express');
const app = express();

app.get('/error', (req, res, next) => {
  // Simulate an error
  const error = new Error("Something went wrong!");
  error.statusCode = 500;
  next(error); // Pass the error to the next middleware
});

// Error handling middleware
app.use((err, req, res, next) => {
  console.error(err.stack); // Log the error stack
  const statusCode = err.statusCode || 500;
  res.status(statusCode).json({
    status: 'error',
    message: err.message || 'Internal Server Error'
  });
});

// Uncomment to run server:
// app.listen(3000, () => console.log('Server running on port 3000'));

5. Custom Error Classes

Creating custom error classes allows for more specific error identification and handling logic. You can extend the built-in Error class to add properties like statusCode, status, or isOperational, making it easier to categorize and respond to different types of errors.

javascript
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = true; // Mark as an error expected to be handled

    // Capture the stack trace, excluding the constructor call itself
    Error.captureStackTrace(this, this.constructor);
  }
}

// Usage example:
// throw new AppError('Invalid input data', 400);

6. Best Practices

  • Log Errors Thoroughly: Use a robust logging solution (e.g., Winston, Pino) to record error details, including stack traces, request data, and user information. This is crucial for debugging and monitoring.
  • Distinguish Operational vs. Programmer Errors: Operational errors (e.g., network timeout, invalid user input, file not found) are expected and can be handled gracefully. Programmer errors (e.g., trying to read a property of undefined) indicate bugs and should typically lead to application restart to prevent further corruption.
  • Avoid Catching All Errors Globally Without Specific Actions: While global handlers are useful, they should primarily log and then initiate a graceful shutdown for critical errors, rather than attempting to continue operation in an unknown state.
  • Graceful Shutdown: On encountering critical errors (especially programmer errors caught by uncaughtException or unhandledRejection), perform necessary cleanup (e.g., close database connections, release resources) and then exit the process, potentially allowing a process manager (like PM2 or Kubernetes) to restart the application.
  • Send Meaningful Responses: For web applications, return appropriate HTTP status codes and clear, but not overly verbose, error messages to the client. Avoid exposing sensitive internal error details to users.