Home
Backend from First Principles / Module 14 — Error Handling

Error Handling

Operational vs programmer errors. Custom error classes. Async pitfalls in Node.


Error Handling Philosophy

Errors are expected. Every external call can fail: database, cache, third-party API. Design your system assuming failures happen.

Categories of errors:

Operational errors — expected runtime errors. DB connection lost, user not found, validation failed. Handle gracefully, return appropriate status.

Programmer errors — bugs. Null pointer, wrong type, logic error. Don't catch these — let them crash and alert. Fix the code.

Transient errors — temporary failures. Network blip, API timeout. Retry with backoff.

Permanent errors — will always fail. Invalid email format, user not found. No point retrying.


Custom Error Classes

Use typed errors to distinguish cases and set HTTP status codes:

JavaScript
class AppError extends Error {
  constructor(message, statusCode = 500, code) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.isOperational = true; // expected error
  }
}

class NotFoundError extends AppError {
  constructor(resource) {
    super(`${resource} not found`, 404, "NOT_FOUND");
  }
}

class ValidationError extends AppError {
  constructor(details) {
    super("Validation failed", 400, "VALIDATION_ERROR");
    this.details = details;
  }
}

class ConflictError extends AppError {
  constructor(message) { super(message, 409, "CONFLICT"); }
}

// In handler, throw typed errors:
throw new NotFoundError("User");
// Error middleware catches and formats the response.

Global Error Middleware

One error handler catches everything. Services/handlers just throw — they don't format HTTP responses.

JavaScript
// Express error middleware (4 args = error handler)
app.use((err, req, res, next) => {
  // Log the error
  logger.error({ err, requestId: req.requestId });

  // Operational error: send clean response
  if (err.isOperational) {
    return res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
        details: err.details
      }
    });
  }

  // Programmer error: don't leak internals
  res.status(500).json({
    error: { code: "INTERNAL_ERROR", message: "Something went wrong" }
  });

  // Optionally: process.exit(1) for unrecoverable programmer errors
});

Async Error Pitfalls

In Node.js, unhandled promise rejections are silent errors (they're caught now, but were historically dropped).

JavaScript
// Dangerous: unhandled rejection
app.get("/users", async (req, res) => {
  const users = await db.getUsers(); // if this throws, who catches it?
  res.json(users);
});

// Safe: wrap in try-catch
app.get("/users", async (req, res, next) => {
  try {
    const users = await db.getUsers();
    res.json(users);
  } catch (err) {
    next(err); // passes to error middleware
  }
});

// Better: use a wrapper utility
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get("/users", asyncHandler(async (req, res) => {
  const users = await db.getUsers();
  res.json(users);
}));

Source & Credit

The Backend from First Principles series is based on what I learnt from Sriniously's YouTube playlist — a thoughtful, framework-agnostic walk through backend engineering. If this material helped you, please go check the original out: youtube.com/@Sriniously. The notes here are my own restatement for revisiting later.

⁂ Back to all modules