CPCODELAB
Node.js

Error Handling Patterns: Beyond try/catch

11 min read

Error Handling Patterns

Production Node.js apps need a layered error strategy: structured error classes, per-operation try/catch, and global safety nets for anything that slips through. Getting this right prevents silent failures and zombie processes.

Structured Error Hierarchy

js
class AppError extends Error {
  constructor(message, code, statusCode = 500) {
    super(message);
    this.name = "AppError";
    this.code = code;          // machine-readable, e.g. "NOT_FOUND"
    this.statusCode = statusCode;
    Error.captureStackTrace(this, this.constructor);
  }
}

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

// Usage
async function getUser(id) {
  if (!id) throw new ValidationError("id is required", { id: "missing" });
  const user = await db.find(id);
  if (!user) throw new AppError("User not found", "NOT_FOUND", 404);
  return user;
}

Process-Level Safety Nets

js
// Catch unhandled Promise rejections (Node.js 15+ terminates by default)
process.on("unhandledRejection", (reason, promise) => {
  console.error("Unhandled rejection at:", promise, "reason:", reason);
  // Log to monitoring (Sentry, Datadog, etc.) then shut down gracefully
  process.exit(1);
});

// Catch synchronous exceptions that escaped all try/catch blocks
process.on("uncaughtException", (err) => {
  console.error("Uncaught exception:", err);
  // Do NOT continue running — the process state is undefined
  process.exit(1);
});

// Graceful shutdown on SIGTERM (sent by Docker, Kubernetes, etc.)
process.on("SIGTERM", async () => {
  console.log("SIGTERM received — shutting down gracefully");
  await server.close();
  process.exit(0);
});

Result Pattern (no-throw alternative)

js
// Return { data, error } instead of throwing — avoids hidden control flow
async function safeReadFile(filePath) {
  try {
    const data = await require("fs/promises").readFile(filePath, "utf8");
    return { data, error: null };
  } catch (err) {
    return { data: null, error: err };
  }
}

const { data, error } = await safeReadFile("config.json");
if (error) {
  console.error("Could not load config:", error.message);
} else {
  console.log("Config loaded:", data);
}

The result pattern (inspired by Go and Rust) makes error paths explicit at the call site and is gaining popularity in TypeScript Node.js codebases. Libraries like neverthrow formalise it with typed Result<T, E> objects.

Ready to test yourself?
Practice Node.js— quiz & coding exercises