Create your own
Lesson illustration

Centralized Error Propagation with Preserved Diagnostics

Good to see you again. Last lesson separated HTTP transport, application policy, and infrastructure adapters so that a use case can be tested and evolved without importing Express or PostgreSQL. That separation gives errors a clear route to travel: code detects or translates a failure, the request boundary receives it, and one centralized handler logs it and produces a consistent HTTP response.

In this lesson, you will implement that route without discarding the information that makes production failures debuggable: the original stack trace, causal chain, stable error codes, and a request correlation ID. The goal is not to catch every error everywhere. It is to make expected failures deliberate, unexpected failures visible, and process-level failures a last-resort safety mechanism. Plan for about 40 minutes.


1. Centralized handling is a policy point, not a giant try...catch

A route that handles errors inline often begins harmlessly:

app.post("/projects", async (req, res) => {
  try {
    const result = await createProject.execute({
      ownerId: req.auth.userId,
      name: req.body.name,
    });

    res.status(201).json({ data: result });
  } catch (error) {
    console.error(error);
    res.status(500).json({
      error: "Something went wrong",
    });
  }
});

The issue is not the try...catch itself. The issue is that every route will soon make its own decisions about:

  • which errors become 400, 404, or 409;
  • what is safe to send to a client;
  • what should be logged and with which metadata;
  • how to include a request ID;
  • whether an underlying error is preserved or accidentally replaced.

Those decisions are cross-cutting operational policy. They belong at a central boundary.

Watch this short segment from Central Error Handling In NodeJS and TypeScript by WebDevLog. Take its distinction between planned operational failures and programmer mistakes as a useful starting point; in this lesson, we will keep HTTP mapping out of application code rather than putting status codes on every error type.

Central Error Handling In NodeJS and TypeScript

Watch Central Error Handling In NodeJS and TypeScript by WebDevLog for the rationale behind central handling and the basic shape of a custom error class.

Watch failure categories to distinguish anticipated operational failures from defects. Then watch custom errors for the idea of extending the native Error object while retaining useful debugging information.

A useful classification for the capstone is:

CategoryExampleClient responseLog severity
Expected application failureProject name is already takenSpecific, safe 409 responseUsually warning
Expected input failurename is missing or invalidSpecific 400 responseUsually info or warning
Infrastructure failureDatabase connection failsGeneric 500 response for nowError, with cause
Unexpected defectAccessing a property on undefinedGeneric 500 responseError, with full stack

The labels are less important than the policy: clients receive a stable, intentional contract; operators receive enough evidence to diagnose the real failure.

Centralized handling does not mean every function catches its own errors. In fact, most application code should either return normally or throw an Error that propagates naturally to the boundary. Catch locally only when you can add meaningful context, translate a known external failure, clean up a resource, or recover.


2. Preserve errors rather than replacing them

A stack trace describes where an Error instance was created. If you catch an error and create a replacement carelessly, you lose the original stack—the part most likely to identify the real failing call.

try {
  await projects.insert(project);
} catch (error) {
  // Avoid: the database error stack is discarded.
  throw new Error("Could not create project");
}

The modern JavaScript and Node mechanism for retaining the original failure is error causation:

try {
  await projects.insert(project);
} catch (cause: unknown) {
  throw new Error("Could not create project", { cause });
}

The new error has a stack pointing to the meaningful translation point, while error.cause retains the database error and its stack. Together, these give the diagnostic narrative:

  1. The infrastructure adapter failed while storing a project.
  2. The underlying database driver explains why.
  3. The request-level logger records both errors alongside the request ID.

Read the relevant parts of the Node.js documentation now. It establishes the main error-propagation channels you need to account for, explains why cause matters, and warns against using mutable error messages as programmatic identifiers.

Errors | Node.js v25.2.1 Documentation

Read the Node.js documentation to understand which execution paths can deliver errors to your central boundary and how error causes preserve diagnostic evidence.

In “Error propagation and interception,” read from asynchronous propagation through the discussion of promise rejections, callback errors, and EventEmitter error events. Then, in “Class: Error,” read the new Error(message[, options]) and error.cause entries, focusing on error chaining. Finally, in “error.code,” note that stable codes are safer for identification than human-readable messages.

Throw Error objects, never strings

This is a small convention with a large payoff:

// Avoid
throw "Project name is already taken";

// Better
throw new Error("Project name is already taken");

A string has no stack, no cause, no standard shape, and no reliable way for centralized code to inspect it. Because third-party libraries or legacy code can still throw arbitrary values, the boundary should defensively normalize an unknown value:

export function toError(value: unknown): Error {
  if (value instanceof Error) {
    return value;
  }

  return new Error("A non-Error value was thrown", {
    cause: value,
  });
}

This conversion is for the boundary. Inside your own codebase, treat throwing anything other than an Error as a defect.

Add stable application codes, not HTTP concerns

Your application layer needs a way to say which expected rule failed without coupling itself to Express or HTTP status codes. A compact custom error provides that vocabulary.

// src/application/shared/app-error.ts

export type AppErrorCode =
  | "PROJECT_NAME_TAKEN"
  | "INVALID_PROJECT_NAME"
  | "PROJECT_STORE_FAILED";

type DiagnosticValue = string | number | boolean;

type AppErrorOptions = {
  code: AppErrorCode;
  message: string;
  context?: Readonly<Record<string, DiagnosticValue>>;
  cause?: unknown;
};

export class AppError extends Error {
  readonly code: AppErrorCode;
  readonly context: Readonly<Record<string, DiagnosticValue>>;

  constructor(options: AppErrorOptions) {
    super(options.message, { cause: options.cause });

    this.name = "AppError";
    this.code = options.code;
    this.context = Object.freeze({ ...options.context });

    Error.captureStackTrace?.(this, AppError);
  }
}

There are several deliberate choices here:

  • It extends Error, preserving normal error behavior and a stack trace.
  • code is a stable machine-readable identifier that your own application owns.
  • message explains the failure to developers, but application logic should not branch on it.
  • context contains small, safe diagnostic facts such as an operation name or a resource ID.
  • cause retains the underlying failure when this error translates another error.
  • There is no HTTP status in this class. HTTP is a transport concern.

Error.captureStackTrace is a Node/V8 feature that removes constructor plumbing from the displayed stack when available. It is useful, but it does not replace proper causal chaining: a clean outer stack is not a substitute for the original database or SDK stack.

Use the error directly for an expected application rule:

import { AppError } from "../shared/app-error.js";

if (existingProject) {
  throw new AppError({
    code: "PROJECT_NAME_TAKEN",
    message: "A project with this name already exists for this owner",
    context: {
      operation: "createProject",
    },
  });
}

Use cause when an infrastructure adapter adds a meaningful translation:

import { AppError } from "../../application/shared/app-error.js";

try {
  await this.pool.query(query, values);
} catch (cause: unknown) {
  throw new AppError({
    code: "PROJECT_STORE_FAILED",
    message: "Project persistence failed",
    context: {
      operation: "projectRepository.insert",
    },
    cause,
  });
}

Do not wrap every error at every layer. Repeated wrappers create noise. Translate only when the outer layer can add a useful fact: the operation attempted, the external capability involved, or a known failure classification.

Also, keep diagnostic context safe. Good examples include operation, projectId, tenantId, and a driver’s error code. Do not put passwords, cookies, authorization headers, access tokens, raw request bodies, or SQL parameter values into an error message or log context.


3. Give all handlers one propagation path

In the previous lesson, a transport handler called a use case and caught failures only to pass them onward:

try {
  const result = await createProject.execute(input);

  res.status(201).json({ data: result });
} catch (error: unknown) {
  next(error);
}

That next(error) call is important. It says: this handler does not decide the final error response. Express skips normal middleware and routes the error to middleware with four parameters.

Keep route handlers as translators, then register one error handler after all routes:

// src/transport/http/create-http-app.ts

import express from "express";
import { requestContextMiddleware } from "./request-context.js";
import { errorMiddleware } from "./error-middleware.js";
import { registerProjectRoutes } from "./projects/project-routes.js";

export function createHttpApp(dependencies: HttpDependencies) {
  const app = express();

  // Install this before parsers and routes so parser failures
  // can also be correlated with a request ID.
  app.use(requestContextMiddleware);
  app.use(express.json());

  registerProjectRoutes(app, dependencies.createProject);

  // Must come after routes.
  app.use(errorMiddleware);

  return app;
}

An error handler maps application errors into HTTP semantics in one place:

// src/transport/http/error-middleware.ts

import type {
  ErrorRequestHandler,
  Request,
} from "express";
import { AppError } from "../../application/shared/app-error.js";
import { getRequestContext } from "./request-context.js";
import { toError } from "../../application/shared/to-error.js";
import { logger } from "../../infrastructure/observability/logger.js";

const publicErrors = {
  PROJECT_NAME_TAKEN: {
    status: 409,
    message: "A project with this name already exists.",
  },
  INVALID_PROJECT_NAME: {
    status: 400,
    message: "Project name is invalid.",
  },
} as const;

function toHttpResponse(error: Error): {
  status: number;
  body: {
    error: {
      code: string;
      message: string;
    };
  };
} {
  if (error instanceof AppError) {
    const mapped =
      publicErrors[error.code as keyof typeof publicErrors];

    if (mapped) {
      return {
        status: mapped.status,
        body: {
          error: {
            code: error.code,
            message: mapped.message,
          },
        },
      };
    }
  }

  return {
    status: 500,
    body: {
      error: {
        code: "INTERNAL_ERROR",
        message: "An unexpected error occurred.",
      },
    },
  };
}

export const errorMiddleware: ErrorRequestHandler = (
  thrown: unknown,
  req: Request,
  res,
  next,
) => {
  if (res.headersSent) {
    next(thrown);
    return;
  }

  const error = toError(thrown);
  const context = getRequestContext();

  logger.error(
    {
      err: error,
      requestId: context?.requestId,
      method: req.method,
      path: req.path,
    },
    "HTTP request failed",
  );

  const response = toHttpResponse(error);

  res
    .status(response.status)
    .json({
      ...response.body,
      requestId: context?.requestId,
    });
};

This code enforces a useful asymmetry:

  • Logs can contain the real error message, stack, causal chain, and safe diagnostics.
  • Clients receive only intentional messages and stable public codes.
  • Unexpected errors never expose a database hostname, query detail, package version, or internal stack trace.

The headersSent branch matters. Once a response has begun streaming, the middleware cannot safely replace it with JSON. Delegating to Express’s default handler is safer than attempting to write a second response.

For the current capstone, mapping error codes to HTTP responses in transport/http/error-middleware.ts is enough. In the API architecture module, you will refine this into a documented, consistent API error format shared across all endpoints.


4. A central logger must preserve the causal chain

The central handler is only valuable if logs retain the error evidence. One common mistake is this:

logger.error(JSON.stringify(error));

Most standard properties of Error, including message and stack, are non-enumerable. Plain JSON serialization can therefore produce {} or omit the important fields. Prefer a structured logger with explicit Error support, and verify that it records:

  • name
  • message
  • stack
  • custom error code and safe context
  • nested cause errors

If you need a small explicit serializer, preserve the chain rather than logging only the outer wrapper:

import { AppError } from "../../application/shared/app-error.js";
import { toError } from "../../application/shared/to-error.js";

export function serializeError(value: unknown): Record<string, unknown> {
  const error = toError(value);

  return {
    name: error.name,
    message: error.message,
    stack: error.stack,
    ...(error instanceof AppError
      ? {
          code: error.code,
          context: error.context,
        }
      : {}),
    ...(error.cause !== undefined
      ? {
          cause: serializeError(error.cause),
        }
      : {}),
  };
}

In a production implementation, add a maximum nesting depth and protection against circular references. The central point remains the same: if you use error wrapping, your logs must display the wrapped cause.

Not every asynchronous failure reaches Express automatically

A centralized Express middleware handles errors that enter Express’s request chain. Node applications also encounter errors through several different mechanisms:

SourceCorrect propagation action
await or a returned promiseLet rejection reach the handler, or catch and call next(error)
Callback APICheck the first err argument and pass it onward
EventEmitter or streamRegister an "error" listener
Detached background promiseAttach an explicit .catch(...) and report it through the relevant job mechanism

For callback-style code, do not throw from inside a later callback and assume the route’s original try...catch will receive it:

legacyClient.loadProject(id, (error, project) => {
  if (error) {
    next(error);
    return;
  }

  res.json({ data: project });
});

For an event emitter, register the listener at the integration boundary:

source.on("error", (error) => {
  next(error);
});

Node treats an unhandled "error" event specially: it can become an uncaught exception and terminate the process. You will apply this principle directly when working with streams in the next lesson.


5. Correlate logs across asynchronous work with AsyncLocalStorage

A stack trace answers, “Where was this error created?” A request ID answers, “Which client operation, logs, and downstream actions belong together?”

Passing requestId manually through every use case and repository is noisy and easy to forget. Node’s AsyncLocalStorage provides request-scoped data that remains available through promise chains and standard asynchronous work.

Read the introduction, the request-ID logging example, and the behavior of run() in the Node documentation.

Asynchronous context tracking | Node.js v25.5.0 ...

Read the Node.js AsyncLocalStorage documentation to see how a request-scoped value can remain available through asynchronous operations without becoming a parameter on every function.

In “Introduction” and “Class: AsyncLocalStorage,” read the storage rationale and the request-ID logger example immediately below it. Then read the asyncLocalStorage.run(store, callback[, ...args]) entry, especially context lifetime. Notice that run() does not alter an error stack trace.

Create one application-wide context store:

// src/transport/http/request-context.ts

import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import type {
  NextFunction,
  Request,
  Response,
} from "express";

export type RequestContext = Readonly<{
  requestId: string;
}>;

const requestContextStorage =
  new AsyncLocalStorage<RequestContext>();

export function getRequestContext():
  | RequestContext
  | undefined {
  return requestContextStorage.getStore();
}

export function requestContextMiddleware(
  _req: Request,
  res: Response,
  next: NextFunction,
): void {
  const context: RequestContext = {
    requestId: randomUUID(),
  };

  res.setHeader("x-request-id", context.requestId);

  requestContextStorage.run(context, () => {
    next();
  });
}

Now an infrastructure logger, an HTTP error middleware, or an outbound client can call getRequestContext() and include the same ID without receiving it as an argument.

Two concurrent requests remain isolated:

Request A: requestId = 8fd...
Request B: requestId = c42...

As asynchronous work created during each request continues, Node maintains the corresponding store. This is similar in purpose to thread-local storage, but it follows Node’s asynchronous execution model rather than an operating-system thread.

Use AsyncLocalStorage narrowly:

  • Store compact correlation data such as requestId, trace identifiers, or a carefully chosen tenant ID.
  • Do not store the full Request, Response, database client, or mutable application state.
  • Do not use it to hide essential business inputs. A use case still needs explicit inputs such as ownerId.
  • If context disappears around an unusual callback API or custom thenable, investigate that integration boundary rather than silently accepting missing IDs.

Your application layer should not import AsyncLocalStorage merely to obtain a request ID. The request context is an operational concern. Application code can attach explicit, safe facts to an AppError; the outer logging and transport layers add request correlation.


6. Process-level handlers are a safety net, not normal control flow

A route error middleware is for failures associated with an active HTTP request. Process events such as uncaughtException and unhandledRejection are different: they mean an error escaped its expected ownership boundary.

Registering handlers for observability is sensible:

process.on("uncaughtException", (error) => {
  logger.fatal({ err: error }, "Uncaught exception");
});

process.on("unhandledRejection", (reason) => {
  logger.fatal(
    { err: toError(reason) },
    "Unhandled promise rejection",
  );
});

But do not treat these events as an invitation to keep running indefinitely. After an uncaught exception, the process may be in an unknown state: a request may have partially mutated in-memory state, skipped cleanup, or left a connection lifecycle inconsistent.

A robust production policy is generally:

  1. Log the failure with as much context as remains available.
  2. Stop accepting new work.
  3. Shut down cleanly within a bounded period.
  4. Exit so a supervisor such as ECS, Kubernetes, or another process manager can restart the service.

The next startup and shutdown lesson will implement that controlled lifecycle. For now, remember the boundary: request middleware handles known request failures; process-level handlers record escaped failures and initiate a safe termination policy.


Key takeaways

Centralized error handling is an architectural boundary that keeps transport behavior consistent and production failures diagnosable:

  • Throw real Error objects, not strings or arbitrary values.
  • Preserve original failures with new Error(message, { cause }) or an AppError that accepts cause.
  • Use stable application error codes for classification; do not parse messages.
  • Keep HTTP mapping in transport middleware, not inside application use cases.
  • Log the complete error chain and safe diagnostic context, while returning only intentional information to clients.
  • Install a request context early and use AsyncLocalStorage to correlate logs across normal asynchronous work.
  • Treat callback, promise, and EventEmitter error paths deliberately; a single Express middleware cannot intercept errors that never enter its request chain.
  • Use process-level handlers as a final safety net, not as routine recovery.

Next, you will apply the same discipline to Node.js streams, processing a large data source while handling stream errors and respecting backpressure.

Can't find a good explanation? Sign up and we'll make it for you

Sign up