hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Error Handling and Resilience with @hectoday/http

The problem with errors

  • Why error handling matters
  • Project setup

Error fundamentals

  • JavaScript error types
  • Try-catch and error propagation
  • Async errors

Structured error handling

  • Custom error classes
  • A global error handler
  • Operational vs programmer errors

Resilience patterns

  • Retries
  • Timeouts
  • Circuit breakers
  • Fallbacks and degradation

Server lifecycle

  • Graceful shutdown
  • Uncaught exceptions and unhandled rejections
  • Health checks under failure

Putting it all together

  • Error handling checklist
  • Capstone: resilient e-commerce API

Uncaught exceptions and unhandled rejections

The last safety net

Our global error handler catches errors thrown inside route handlers. But not every error happens inside a route handler. What about a bug in a timer callback? An error in an event listener? A rejected promise in a fire-and-forget operation?

These errors escape the global handler. They reach the process level. Without handling at this level, uncaught exceptions crash the server and unhandled rejections produce warnings. We need a final safety net.

Uncaught exceptions

An uncaught exception means an error propagated all the way up the call stack and nobody caught it:

// This error is not inside a route handler, no global handler catches it
setTimeout(() => {
  throw new Error("Bug in a timer callback");
}, 1000);

The error fires inside a setTimeout callback. That callback is not inside a route handler, so the onError callback does not see it. The error goes straight to Node.js, which by default prints the stack trace and might crash the process.

We can catch these at the process level:

process.on("uncaughtException", (err) => {
  console.error(
    JSON.stringify({
      level: "fatal",
      event: "uncaught_exception",
      message: err.message,
      stack: err.stack,
    }),
  );

  // After an uncaught exception, the process is in an unknown state.
  // In-flight requests might have left data half-written.
  // The safest action is to shut down and let the orchestrator restart.
  shutdown("uncaughtException");
});

The handler logs the error at the “fatal” level (the highest severity) and then calls the shutdown function from the previous lesson.

[!WARNING] After an uncaught exception, the process state is unreliable. Database transactions might be half-committed. In-memory data might be inconsistent. Do not try to “recover” and keep serving requests. Log the error, alert the team, and exit. The Docker restart policy (from the Deploying course) restarts the container automatically.

This is a key point. The uncaught exception handler is not about recovery. It is about dying gracefully instead of dying suddenly. You log what happened so the team can investigate, you clean up resources, and you exit.

Unhandled rejections

A promise rejected without a .catch() or an await in a try-catch:

// This promise rejects, but nothing handles it
async function doSomething() {
  throw new Error("Oops");
}
doSomething(); // No await, no .catch()

We handle these similarly:

process.on("unhandledRejection", (reason) => {
  console.error(
    JSON.stringify({
      level: "error",
      event: "unhandled_rejection",
      message: reason instanceof Error ? reason.message : String(reason),
      stack: reason instanceof Error ? reason.stack : undefined,
    }),
  );

  // Unhandled rejections are bugs, but less severe than uncaught exceptions.
  // Log and alert, but do not necessarily crash. The process state
  // is usually still valid because the rejection was in async code
  // that did not modify shared state.
});

There is an important difference from uncaught exceptions. An unhandled rejection happens in async code that was not awaited. The main synchronous code path is usually unaffected. The process state is typically still valid. So you might choose to log the error and continue rather than crashing.

Both are bugs that need fixing. But an uncaught exception is more dangerous because it happened in synchronous code that might have left shared state in an inconsistent condition.

The complete process-level setup

Let’s put all the process-level handlers together in one place:

Code along
// src/process-handlers.ts

export function setupProcessHandlers(shutdownFn: (signal: string) => void): void {
  // Graceful shutdown on signals
  process.on("SIGTERM", () => shutdownFn("SIGTERM"));
  process.on("SIGINT", () => shutdownFn("SIGINT"));

  // Last-resort error handlers
  process.on("uncaughtException", (err) => {
    console.error(
      JSON.stringify({
        level: "fatal",
        event: "uncaught_exception",
        message: err.message,
        stack: err.stack,
      }),
    );
    shutdownFn("uncaughtException");
  });

  process.on("unhandledRejection", (reason) => {
    console.error(
      JSON.stringify({
        level: "error",
        event: "unhandled_rejection",
        message: reason instanceof Error ? reason.message : String(reason),
        stack: reason instanceof Error ? reason.stack : undefined,
      }),
    );
    // Log but do not crash for unhandled rejections
  });
}

Then wire it up in your server file:

Code along
// src/server.ts
import { setupProcessHandlers } from "./process-handlers.js";

// ... server setup ...

setupProcessHandlers(shutdown);

One function call sets up everything: signal handlers for graceful shutdown, the uncaught exception handler that shuts down on fatal errors, and the unhandled rejection handler that logs but continues.

The error handling hierarchy

Let’s step back and look at the full picture. Every error in our system is caught by one of these layers:

Route handler checks input/state
  |-- Returns error response directly (400, 404, 409, etc.)

Route handler encounters unexpected error
  |-- Global error handler (onError callback) catches it
      |-- Returns proper HTTP response

Error escapes route handler
  |-- uncaughtException catches it
      |-- Logs, alerts, shuts down

Promise rejects without handler
  |-- unhandledRejection catches it
      |-- Logs, alerts

Nothing catches it
  |-- Node.js crashes with a stack trace

Each level is a safety net for the level above. Routes return errors directly for expected cases. The global error handler catches unexpected throws. Process handlers catch what escapes entirely. The goal is to never reach the bottom: a crash with no logging and no cleanup.

In practice, if your routes handle expected errors by returning responses and the global error handler catches unexpected ones, the process handlers should fire rarely. When they do fire, that is a signal that there is a bug somewhere: an async function called without await, an error in a place you did not expect.

Exercises

Exercise 1: Add the process-level handlers. Throw an error inside a setTimeout (outside a route handler). Verify uncaughtException catches it and triggers shutdown.

Exercise 2: Create an unhandled promise rejection (an async function that throws, called without await). Verify unhandledRejection catches it and logs the error.

Exercise 3: Verify the full hierarchy: return an error from a route (handled directly), cause a throw in a route (caught by global handler), throw in a timer (caught by uncaughtException), reject without handler (caught by unhandledRejection).

We have safety nets at every level now. But there is still one gap: how does the outside world know whether our app is healthy? A load balancer keeps sending traffic to our server even if the database is down. Next, we will build health checks that actually reflect the state of our dependencies.

Why should the process exit after an uncaught exception?

← Graceful shutdown Health checks under failure →

© 2026 hectoday. All rights reserved.