Error Handling

Difficulty

Answer: Each async style has its own error channel; mixing them incorrectly is where bugs hide.

Callbacks — error-first argument:

fs.readFile('f.txt', (err, data) => {
  if (err) return handle(err);
  use(data);
});

Promises — .catch:

doWork()
  .then(use)
  .catch(handle);   // catches rejections from the whole chain

async/await — try/catch:

try {
  const data = await readFile('f.txt');
  use(data);
} catch (err) {
  handle(err);
}

Critical subtleties:

  • A try/catch around await only catches the awaited promise. If you forget await, the error escapes as an unhandled rejection:
try {
  doAsync(); // ❌ no await — a rejection here is NOT caught
} catch (e) {}
  • Errors thrown inside a callback passed to something else aren't caught by an enclosing try/catch:
try {
  setTimeout(() => { throw new Error('boom'); }, 0); // ❌ escapes — crashes process
} catch (e) {}
  • In Express, synchronous throws in a handler are caught by Express, but rejected promises are not (pre-Express 5) — you must call next(err) or use an async wrapper.

Rule: match the handler to the async mechanism, and always await (or .catch) every promise.

Answer: These are last-resort process-level events, not a substitute for local error handling.

uncaughtException — a thrown error propagated to the top of the stack with no try/catch:

process.on('uncaughtException', (err) => {
  logger.fatal(err);
  // do minimal synchronous cleanup, then exit
  process.exit(1);
});

unhandledRejection — a promise rejected with no .catch/try-catch:

process.on('unhandledRejection', (reason) => {
  logger.error('Unhandled rejection', reason);
  process.exit(1);
});

(In modern Node, an unhandled rejection terminates the process by default.)

Why you should exit, not continue:

  • After an uncaught exception the application is in an undefined state — resources may be half-updated, locks held, connections dangling. Continuing risks corrupted data and memory leaks.
  • Best practice (per Node docs): log, release critical resources, and let the process crash, then rely on a process manager (PM2, systemd, Kubernetes) to restart a clean instance.

What NOT to do:

// ❌ Anti-pattern: swallow and keep running
process.on('uncaughtException', () => { /* ignore */ });

This turns crashes into silent corruption.

The real fix: handle errors where they occur (try/catch, .catch, error middleware). These global handlers are a safety net for logging and orderly shutdown — not normal control flow.

Answer: This distinction (popularized by Joyent's Node error-handling guide) drives how you should respond to an error.

Operational errors — runtime conditions a correct program will encounter:

  • Failed DNS lookup, connection reset, request timeout.
  • Invalid user input, 404 from an upstream API, disk full, file not found.
  • These are expected. Handle them: validate, retry with backoff, return a proper error response, use a fallback.
try {
  const res = await fetch(url);
  if (!res.ok) return reply(res.status, 'Upstream error');
} catch (err) {
  // network timeout etc. — operational
  return reply(503, 'Service unavailable');
}

Programmer errorsbugs in your code:

  • undefined is not a function, reading a property of null, passing the wrong type, forgetting to await.
  • You cannot sensibly recover from these at runtime because the program's assumptions are broken. The right response is to let it crash, log with a stack trace, and fix the code.

Why it matters:

  • Wrapping everything in try/catch and swallowing errors hides programmer bugs and leaves the process in a bad state.
  • Retrying a programmer error just repeats the bug.

Practical guidance:

  • Handle operational errors explicitly and locally.
  • Let programmer errors bubble to the top; use uncaughtException/unhandledRejection only to log and restart.
  • Custom error classes (below) help you distinguish the two (e.g., an isOperational flag).

Answer:

Custom error classes carry structured context beyond a message:

class AppError extends Error {
  constructor(message, statusCode = 500) {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.isOperational = true;          // distinguishes expected errors from bugs
    Error.captureStackTrace(this, this.constructor);
  }
}

class NotFoundError extends AppError {
  constructor(resource) { super(`${resource} not found`, 404); }
}
class ValidationError extends AppError {
  constructor(msg) { super(msg, 400); }
}

Propagating in Express:

app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await db.getUser(req.params.id);
    if (!user) throw new NotFoundError('User');
    res.json(user);
  } catch (err) {
    next(err);          // hand off to the error middleware
  }
});

(An asyncHandler wrapper can remove the repetitive try/catch by catching rejections and calling next for you. Express 5 forwards async rejections automatically.)

Centralized error-handling middleware — note the four parameters (that's how Express recognizes it), registered last:

app.use((err, req, res, next) => {
  const status = err.statusCode || 500;
  // Log full details server-side; return a safe message to the client
  if (!err.isOperational) logger.error(err);
  res.status(status).json({ error: err.message });
});

Benefits: one place to format responses, log, and hide internal details; handlers stay focused on the happy path; the isOperational flag lets you decide what to expose vs. treat as a crash.

Answer: Graceful shutdown ensures a deploy, scale-down, or Ctrl+C doesn't drop in-flight requests or leave resources dangling.

The sequence:

  1. Catch termination signals (SIGTERM from orchestrators/containers, SIGINT from Ctrl+C).
  2. Stop accepting new workserver.close() stops new connections but lets in-flight requests finish.
  3. Drain / close resources — DB pools, Redis, message-queue consumers, open files.
  4. Exit with code 0, or force-exit after a timeout if something hangs.
const server = app.listen(3000);

async function shutdown(signal) {
  console.log(`${signal} received, shutting down...`);

  // Stop accepting new connections; finish in-flight ones
  server.close(async () => {
    try {
      await db.end();          // close DB pool
      await redis.quit();      // close cache
      process.exit(0);
    } catch (err) {
      console.error('Error during shutdown', err);
      process.exit(1);
    }
  });

  // Safety net: don't hang forever
  setTimeout(() => {
    console.error('Forced shutdown after timeout');
    process.exit(1);
  }, 10_000).unref();
}

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

Why it matters:

  • Kubernetes sends SIGTERM then waits (terminationGracePeriodSeconds) before SIGKILL. Handling SIGTERM lets running requests complete during a rolling deploy → zero dropped requests.
  • Closing DB/queue connections cleanly avoids leaked connections and half-processed messages.
  • .unref() on the timeout ensures it doesn't itself keep the process alive.

Tip: also fail readiness checks first so the load balancer stops routing new traffic before you close the server.