DEV Community

Ansh Sheladiya
Ansh Sheladiya

Posted on

Error Handling in Node.js: Patterns for Reliable and Resilient Applications

Error handling is one of the clearest differences between a Node.js prototype and a production-ready application. A reliable backend does not simply prevent errors; it detects failures, records useful context, responds safely, and keeps the process healthy whenever possible.

Node.js gives developers several ways to handle failures, including try/catch, Promise rejection handling, custom errors, error-first callbacks, and centralized middleware in frameworks such as Express. Choosing the right strategy depends on where the error occurs and whether the failure is expected, recoverable, or fatal.

Building a Robust Error Handling Strategy in Node.js

A good Node.js error-handling strategy starts by separating operational errors from programming errors. Operational errors include invalid user input, missing resources, unavailable external services, and database timeouts, while programming errors usually indicate bugs that should be fixed rather than silently recovered from.

For synchronous code, try/catch provides a straightforward way to intercept exceptions. With asynchronous code, Promise-based APIs should use try/catch around awaited operations or explicit catch handlers, while callback-based APIs conventionally pass errors through the first callback argument.

Custom error classes make failures easier to classify and handle consistently. Instead of returning different error structures throughout an application, a custom AppError can carry an HTTP status code, an operational flag, and a stable error code that higher-level handlers can use.

The example below demonstrates a small service layer with custom errors, asynchronous operations, centralized handling, structured logging, and graceful handling of unexpected failures. The flow intentionally logs each major step so you can see how an error moves from the service layer to the final response.

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

function logStep(message, data = {}) {
  console.log(`[LOG] ${message}`, data);
}

function logError(error) {
  console.error("[ERROR]", {
    name: error.name,
    message: error.message,
    code: error.code,
    statusCode: error.statusCode,
    stack: error.stack
  });
}

async function findUser(userId) {
  logStep("Searching for user", { userId });

  // Simulate an asynchronous database operation.
  await new Promise((resolve) => setTimeout(resolve, 300));

  if (!userId) {
    throw new AppError(
      "User ID is required",
      400,
      "USER_ID_REQUIRED"
    );
  }

  if (userId === 404) {
    throw new AppError(
      "User was not found",
      404,
      "USER_NOT_FOUND"
    );
  }

  logStep("User found successfully", { userId });
  return { id: userId, name: "Ansh", active: true };
}

async function loadUserProfile(userId) {
  logStep("Loading user profile");

  try {
    const user = await findUser(userId);

    if (!user.active) {
      throw new AppError(
        "User account is inactive",
        403,
        "USER_INACTIVE"
      );
    }

    logStep("Profile loaded successfully", { user });
    return user;
  } catch (error) {
    // Add useful context before passing the error upward.
    logStep("Profile service caught an error", {
      userId,
      error: error.message
    });
    throw error;
  }
}

function createResponse(error) {
  const statusCode = error.statusCode || 500;
  const isOperational = error.isOperational === true;

  // Never expose sensitive stack information to clients.
  return {
    success: false,
    statusCode,
    code: error.code || "INTERNAL_ERROR",
    message: isOperational
      ? error.message
      : "An unexpected error occurred"
  };
}

async function handleRequest(userId) {
  logStep("Starting request", { userId });

  try {
    const profile = await loadUserProfile(userId);

    logStep("Request completed successfully");

    return {
      success: true,
      statusCode: 200,
      data: profile
    };
  } catch (error) {
    logStep("Central error handler received an error");
    logError(error);

    const response = createResponse(error);
    logStep("Sending safe error response", response);

    return response;
  }
}

async function main() {
  console.log("=== Node.js Error Handling Demo ===\n");

  console.log("1. Testing a successful request");
  console.log(await handleRequest(101));

  console.log("\n2. Testing a missing user");
  console.log(await handleRequest(404));

  console.log("\n3. Testing invalid input");
  console.log(await handleRequest(null));

  console.log("\n4. Demonstrating unexpected errors");
  try {
    throw new TypeError("Unexpected programming error");
  } catch (error) {
    logError(error);
    console.log(createResponse(error));
  }

  console.log("\n=== Demo completed ===");
}

// Handle unexpected Promise rejections at the process level.
process.on("unhandledRejection", (reason) => {
  console.error("[FATAL] Unhandled Promise rejection detected");
  console.error(reason);
});

// Catch uncaught exceptions as a last-resort safety net.
process.on("uncaughtException", (error) => {
  console.error("[FATAL] Uncaught exception detected");
  console.error(error);
  process.exit(1);
});

main().catch((error) => {
  logError(error);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

Effective error handling is not about wrapping every line of Node.js code in try/catch. It is about creating predictable boundaries where errors can be classified, logged, transformed, and handled without exposing internal implementation details to users.

Custom errors, centralized handling, structured logs, and correct Promise rejection management provide a strong foundation for production applications. At the same time, unexpected programming errors should not be hidden because silently continuing from a corrupted application state can create even larger failures.

The goal is resilience without losing visibility. When every failure has enough context to diagnose and a consistent path to a safe response, Node.js applications become significantly easier to operate, debug, and scale.

Top comments (0)