FullStack Course LogoFullStack Course
Module: Nodejs
Nodejs·120·13 MIN READ

120: Node Errors, Stack Traces, Async Failures, Assertions, and Diagnostic Context

TOPICS COVERED: Node Errors, Stack Traces, Async Failures, Assertions, and Diagnostic Context

Learning objectives

You will learn to:

  • distinguish JavaScript errors, system errors, assertion errors, and application/domain errors;
  • create error classes with useful metadata;
  • preserve error causes;
  • handle Promise rejections and callback errors;
  • understand uncaught exceptions and unhandled rejections;
  • read stack traces;
  • use node:assert appropriately;
  • use source maps;
  • attach request context safely with AsyncLocalStorage;
  • design error boundaries for CLI, worker, and server processes;
  • avoid swallowing failures.

The aim is not just to make an error disappear. In production, you need to know what failed, whether the failure was expected, what context is safe to expose, and whether the process can continue safely. Those decisions are different for a validation failure, a missing file, and a broken invariant.

Error taxonomy

When Node reports an error, start by identifying what kind of failure it represents. The category usually tells you how to handle it and what information is useful to preserve.

JavaScript errors

These errors come from the JavaScript language or runtime itself. The name often identifies the kind of operation that could not be completed.

Examples:

text
TypeError
ReferenceError
SyntaxError
RangeError

For example, trying to read a property from null is a type error because the value does not support that operation:

js
null.name;
// TypeError

The exact message can vary between Node versions, but the error class and stack are still useful diagnostic information.

System errors

Node translates many operating-system failures into JavaScript Error objects. A file that does not exist, a refused network connection, and a permission failure are not JavaScript mistakes in the narrow sense; they are failures reported by the underlying system.

Example:

js
import { readFile } from 'node:fs/promises';

await readFile('/missing/file');

The resulting error can include fields such as:

text
code: ENOENT
errno
syscall
path

Use a stable error code where Node documents one. Do not make program behavior depend on matching the human-readable message, because messages are intended for people and can change between versions or platforms. The path, syscall, and code also help you distinguish a missing file from a permission problem when debugging.

Assertion errors

The node:assert module is useful when a condition must be true for the program or test to be correct:

js
import assert from 'node:assert/strict';

assert.equal(2 + 2, 4);

Assertions are excellent in tests and for checking internal invariants. They express a programming assumption, so an assertion failure usually indicates a defect that developers need to investigate.

They are not a substitute for validating input supplied by a user or an API client:

js
assert(user.email);

If user.email is absent, this produces an assertion crash rather than a controlled validation response. Validate public input deliberately and return an appropriate error shape instead of exposing an internal assertion failure as API validation.

Application/domain errors

Application errors represent conditions that are meaningful in the language of your system. A caller may need to distinguish a missing resource from a conflict or invalid input, even though all of them ultimately involve an Error object.

Create meaningful classes when that distinction is useful:

js
export class NotFoundError extends Error {
  constructor(resource, options = {}) {
    super(`${resource} was not found`, options);

    this.name = 'NotFoundError';
    this.code = 'NOT_FOUND';
    this.status = 404;
  }
}

The class gives code a stable type and gives the boundary a status and machine-readable code. In a larger application, you might also carry structured details, but those details should be selected with the eventual audience in mind.

Useful machine-readable fields include:

text
code
status
details
cause

Keep a safe user message separate from internal diagnostic detail. A caller may need to know that a task was not found, but should not receive a database query, local file path, or secret that happened to be present in the underlying error.

Error causes

Wrapping an error is often the right way to add context, but only if the original failure remains available. The cause option does that without flattening the original object into a string.

js
try {
  await readFile(configPath, 'utf8');
} catch (cause) {
  throw new Error('Could not load application configuration', {
    cause,
  });
}

The caller receives a meaningful operation-level message, while logging and diagnostic tooling can inspect the underlying failure. This is especially valuable when several layers add context as an error moves from a filesystem or database adapter toward an HTTP or CLI boundary.

Do not concatenate every message into one string. Doing so loses the structured cause chain, the original error type, and fields such as a system error code. A logger can walk the chain and record it in a form that remains searchable.

Throwing non-Error values

JavaScript permits throwing values that are not Error objects, but that makes failure handling less predictable. Avoid:

js
throw 'failed';

Prefer:

js
throw new Error('failed');

Error objects provide a stack and standard behavior, and they work with the conventions used by Node tooling and logging libraries. Code that catches an error can then safely inspect properties such as name, message, cause, and, when present, an application-specific code.

Stack traces

A stack trace shows where an error was created and the calls that led there. Consider this example:

text
Error: Could not load task
    at getTask (.../task-service.js:42:11)
    at async handler (.../routes.js:18:16)

Read the application frames from the top outward. The first relevant frame often identifies where the error was constructed or thrown; the next frames show how execution reached that point. An async frame tells you that the call crossed an asynchronous boundary, but it does not mean the error is less actionable.

When inspecting a trace, ask:

  • where was the error created?
  • where did the async call originate?
  • is the top frame generated/transpiled?
  • is there a cause?

Also check whether the stack points at your source or at generated code. The answer determines whether source maps are needed before the location can be interpreted confidently.

Source maps

Transpilers and bundlers can change the line and column locations that appear in a runtime stack. Source maps connect those generated locations back to the source files a developer actually wrote.

Modern Node supports source-map options and features. Make sure the way your application is built and started enables the behavior your diagnostic tooling expects; otherwise a trace may point only to bundled output.

Source maps can contain source code that should not be public. Protect production maps appropriately. They may remain available to server-side error tooling without being exposed as web-served static files. This gives operators useful source locations without making implementation details available to untrusted clients.

Promise failures

An async function returns a Promise, so a thrown error becomes a rejected Promise. In this example, main() does not synchronously throw into the surrounding caller:

js
async function main() {
  throw new Error('boom');
}

await main();

At a top-level boundary, catch the rejection where you can decide what the process should do:

js
try {
  await main();
} catch (error) {
  console.error(error);
  process.exitCode = 1;
}

Setting process.exitCode records failure while allowing normal synchronous cleanup to finish. A server, worker, or CLI may choose a different boundary policy, but the decision should be explicit rather than an accidental consequence of a missing catch.

Callback errors

Many Node-style callback APIs use the error-first convention:

js
callback(error, value)

The callback must handle the error before using the value:

js
import { readFile } from 'node:fs';

readFile('data.json', 'utf8', (error, text) => {
  if (error) {
    console.error(error);
    return;
  }

  console.log(text);
});

The return is significant: it prevents the success path from running after a failure. Never continue to use text after an error unless that particular API contract says the value is valid even when error is present.

Promise APIs are often easier for new application code because try/catch composes naturally with async/await. Callback APIs remain common in legacy code, event-driven integrations, and APIs whose contracts you cannot change, so the error-first rule still matters.

promisify

For a legacy callback API, promisify can adapt a conventional error-first function:

js
import { promisify } from 'node:util';

const legacyAsync = promisify(legacyFunction);

The returned function produces a Promise that rejects when the callback receives an error. Prefer a first-party Promise API such as node:fs/promises when one is available. It usually avoids an adapter and makes the intended asynchronous style clearer.

Unhandled rejection

An unhandled rejection occurs when a Promise rejects and no handler observes it. This is generally a programmer or lifecycle defect: some operation was started without a complete ownership and error-handling path.

Do not install a global handler merely to suppress the warning or make the process appear healthy. At the process level, observe and log the failure, then terminate according to the service's policy. More importantly, fix the missing handling at the source so the operation has a clear owner.

The exact process behavior can depend on the Node version and runtime configuration, so do not rely on accidental defaults for service design. Treat an unhandled rejection as a signal that the lifecycle needs attention.

Uncaught exception

An uncaught exception is a synchronous exception that reaches the event loop without a catch. At that point, application state may be unknown: a mutation may have started, a lock may be held, or an invariant may already have been violated.

The recommended production mindset is:

text
record safely
begin shutdown if possible
allow supervisor restart

Do not continue serving indefinitely after an uncaught exception. A process supervisor, container platform, or service manager can restart a process after it exits, while the boundary gets a chance to preserve the useful diagnostic record and perform bounded cleanup.

Global handlers are last-resort boundaries

Global handlers can provide a final observation and shutdown boundary, but they are not a replacement for local handling:

js
process.on('uncaughtException', (error) => {
  logger.fatal({ error }, 'uncaught exception');
  beginShutdown();
});

process.on('unhandledRejection', (reason) => {
  logger.fatal({ reason }, 'unhandled rejection');
  beginShutdown();
});

The shutdown implementation must guard against re-entrant shutdown. Both handlers can be triggered close together, and repeated cleanup attempts can create a second failure while the process is already stopping.

Do not perform long, unreliable asynchronous recovery from a process whose state may be corrupted. Record what you can safely record, attempt bounded cleanup, and let the supervisor restart the service when that is the established policy.

Expected versus unexpected errors

The response to an error depends on whether the condition is part of normal application behavior or evidence of a defect.

Expected conditions include:

text
invalid input
record not found
permission denied
business conflict
rate limit

Unexpected failures include:

text
null dereference
invariant violation
database driver bug
coding error

Expected errors should map to controlled API or CLI behavior. For example, invalid input may produce a validation response, while a missing record may produce a stable not-found code. Unexpected errors should be observed and contained at boundaries; they should not be silently converted into a normal-looking success or an ambiguous null.

This distinction is not always determined by the JavaScript class alone. A system error such as permission denied might be expected at one boundary and a deployment defect at another. The component that understands the operation has to classify it.

Result versus throw

Not every negative outcome needs to throw. Exceptions are useful for exceptional control flow, but ordinary user-input validation can be clearer when represented as data.

Parsing a port value could return a result like this:

js
function parsePort(raw) {
  const value = Number(raw);

  if (!Number.isInteger(value)) {
    return {
      ok: false,
      error: 'PORT must be an integer',
    };
  }

  return { ok: true, value };
}

The caller can inspect ok and handle expected invalid input without using a try/catch for normal branching. This approach does not mean exceptions are wrong. Use throws for exceptional control flow when that fits the architecture, and use explicit results when a failure is an ordinary outcome the caller is expected to examine.

Error wrapping without losing identity

Adding context is useful, but a wrapper must not erase the identity that downstream code may need. This version is a bad wrapper:

js
catch (error) {
  throw new Error(error.message);
}

It loses the original type, code, cause, and often the most useful structured fields.

A better wrapper adds domain context and preserves the cause:

js
catch (cause) {
  throw new DatabaseUnavailableError('Could not load tasks', {
    cause,
  });
}

If you have no context to add, rethrow the original error unchanged. Wrapping should make the failure easier to understand, not merely move it to a new object.

node:assert

Use strict assertions in tests to verify the behavior under test:

js
import assert from 'node:assert/strict';

assert.deepEqual(
  normalizeTask({ title: ' A ' }),
  { title: 'A' },
);

Assertions are also appropriate for internal invariants. If the application has reached a state in which configuration must have a positive port, an assertion makes that assumption explicit:

js
assert.ok(config.port > 0);

For public runtime inputs, return controlled validation errors instead. The caller supplied the input, so a bad value is part of the interface contract, not automatically an internal programming defect.

AsyncLocalStorage

A server often needs a request or correlation ID in logs produced several layers below the route handler. Passing that ID through every function signature solely for diagnostics can make otherwise unrelated APIs noisy.

AsyncLocalStorage provides context associated with an asynchronous execution flow:

js
import { AsyncLocalStorage } from 'node:async_hooks';

export const requestContext = new AsyncLocalStorage();

At the request boundary, establish the context before invoking the request work:

js
requestContext.run(
  { requestId: crypto.randomUUID() },
  () => {
    handleRequest();
  },
);

Deep inside a service, code can read the store when it needs diagnostic context:

js
const context = requestContext.getStore();

logger.info(
  { requestId: context?.requestId },
  'loading task',
);

The optional chaining matters because code can also run outside a request context, such as a startup task, test, or background job. This technique avoids passing a request ID through every function signature purely for diagnostics.

Do not use AsyncLocalStorage to hide core business dependencies. Values such as the current user's authorization context can be security-critical; explicit arguments often make those dependencies and checks easier to see and review.

Structured logging

Logs are easier to search and correlate when error data remains structured:

js
logger.error({
  err: error,
  requestId,
  taskId,
}, 'task update failed');

This is more useful than flattening everything into a string:

js
console.log('ERROR ' + error);

The structured form lets logging systems index the error, request ID, and task ID separately while still retaining the human-readable event message.

Redact sensitive values, including:

  • authorization headers;
  • passwords;
  • tokens;
  • sensitive user data.

Diagnostic context is valuable only if collecting it does not turn an incident into a data leak. Apply the same care to error metadata and serialized causes as you do to ordinary application logs.

Error response safety

A server should not return raw internal diagnostics to an untrusted client, including:

text
stack trace
SQL/Mongo query internals
file paths
environment details

Those details can reveal implementation information, credentials embedded in configuration, or data useful to an attacker. Return a stable public error code and message instead, and log the internal detail securely where operators can investigate it.

Error handling in cleanup

Cleanup is another operation that can fail. For example, a server close may reject rather than completing cleanly:

js
try {
  await server.close();
} catch (error) {
  logger.error({ error }, 'server close failed');
}

During shutdown, continue attempting independent cleanup within a deadline. One failed cleanup step should not automatically prevent an unrelated resource from being released, but shutdown must remain bounded so the process does not hang forever waiting for a broken dependency.

AggregateError

Some operations have more than one meaningful failure. AggregateError represents multiple concurrent failures as one error value.

Promise.any can reject with an AggregateError when every candidate Promise rejects. Batch systems may also deliberately aggregate failures so the caller can inspect all failed items rather than receiving only the first one.

At a UI or API boundary, decide how much of that detail is safe to expose. The internal error may contain several causes, while the public response may need only a stable summary and an incident or correlation ID.

Error monitoring

Production systems benefit from several complementary signals:

  • error tracking;
  • structured logs;
  • request correlation;
  • metrics;
  • traces/APM.

A stack trace without request context can be insufficient in a distributed system. The trace may identify the code location, but correlation, timing, request identifiers, and related service events help establish which operation failed and what the user or downstream service experienced.

Failure clinic

These patterns often look harmless during a quick code review, but each one removes information or makes an unsafe process decision.

Empty catch

js
try {
  ...
} catch {}

This swallows the failure. Only intentionally ignore errors that you have explicitly classified as safe to ignore, and make that decision visible in the surrounding code. Otherwise the next layer may report a misleading success.

Catch and return null

js
catch {
  return null;
}

Now "not found" and "database down" look identical. Callers cannot choose the correct response, and an infrastructure outage may be mistaken for an empty result.

Leaking raw errors to API

This is both a security and a user-experience issue. Internal messages and stacks are not a stable public contract and may expose implementation details.

Global handler continues process

Continuing after a process-level failure may serve requests with corrupted or partially updated state. A last-resort handler should record the problem and move toward a controlled shutdown instead.

Logging secrets

If passwords, tokens, authorization headers, or sensitive user data enter logs, an incident can become a second data leak. Redaction needs to cover structured fields and nested error details, not just the top-level message.

Exercises

  1. Create NotFoundError, ValidationError, and ConflictError. Give each one a useful machine-readable code and an appropriate public status.
  2. Wrap a filesystem error with cause, then inspect both the wrapper and the underlying system error.
  3. Inspect stack traces through three async functions. Identify the application frame where the error was created and the frame where the operation began.
  4. Convert a callback API to a Promise API. Verify that the callback error becomes a rejected Promise and is handled by the caller.
  5. Build a top-level CLI error boundary. Print a safe message, preserve the diagnostic error for logs, and set a failing exit code.
  6. Add AsyncLocalStorage request IDs to a small HTTP server. Confirm that a deep service log includes the request ID and that code outside a request context still behaves safely.
  7. Force an unhandled rejection in a test process and document production policy. Include what gets logged, whether shutdown begins, and how a supervisor restarts the process.
  8. Design public versus internal error shapes. Identify which fields are safe for an untrusted client and which belong only in secure logs.

Mastery checklist

Explain:

  • error taxonomy;
  • system error codes;
  • causes;
  • async failure handling;
  • unhandled rejection;
  • uncaught exception;
  • assertions;
  • stack/source maps;
  • AsyncLocalStorage;
  • structured safe logging;
  • expected versus unexpected errors.

You should be able to explain not only each term, but also the boundary decision it implies: what a caller receives, what an operator records, and whether the process is safe to continue.

Official references

Reader page: /nodejs/lesson/120/node-errors-stack-traces-async-failures-assertions-and-diagnostic-context