error.cause: Building error chainsand never losing original exceptions
In JavaScript, the original error gets lost on re-throw: an Error is replaced by another one, and the context disappears. The error.cause feature from ES2022 solves exactly this problem. It allows structured error chains where each layer keeps its own context while the root error is passed through.
Table of Contents
- 1. The problem: error context gets lost
- 2. Syntax and basic principle of error.cause
- 3. Custom error classes with cause support
- 4. error.cause in asynchronous code and promises
- 5. Structured error logging with error chains
- 6. error.cause in Node.js: fetch, fs and database errors
- 7. Error chaining in production monitoring tools
- 8. Antipatterns in error handling without error.cause
- 9. Error handling patterns compared
- 10. Summary
- 11. FAQ
1. The problem: error context gets lost
Every JavaScript developer knows the pattern: a function catches an exception but wants to report a more understandable error to the caller. The obvious approach is throw new Error('Operation failed') inside the catch block. The result: the original error, with its stack trace, its message and its type, is irretrievably lost. In production monitoring, a generic "Operation failed" error shows up, without any hint of the actual cause. For debugging, that is worthless.
Before ES2022, developers helped themselves with various workarounds: attaching the original error as a property on the new Error (newError.originalError = caughtError), embedding it in the error message (new Error('Operation failed: ' + originalError.message)), or writing complex custom error classes that accept a cause parameter in the constructor. None of these approaches were standardized, and no tool could reliably build on them. JavaScript error.cause standardizes this pattern as part of ES2022, supported by all modern runtimes.
2. Syntax and basic principle of error.cause
The syntax is deliberately simple: the Error constructor accepts an options object as its second argument, with a cause property. The original error object (or any other value) is passed in there and is subsequently accessible via error.cause. This works with the built-in Error type as well as with all specialized built-in types like TypeError, RangeError, SyntaxError, and custom subclasses. The cause property is not limited to errors; it can hold any value, but in practice the causing error object is the most sensible value.
The core idea behind error.cause is to create error chains that provide complete context information when traversed. Every level of the call stack can enrich the error message with its specific context while passing on the original error unchanged. The result is a chain of errors in which the innermost error carries the technical cause and every outer error adds business context. This structure is readable for humans and searchable by automated analysis tools.
// Basic error.cause usage, ES2022 standard
async function loadUserProfile(userId) {
let rawData;
try {
rawData = await fetchFromDatabase('users', userId);
} catch (dbError) {
// Wrap the database error with business context, preserve original
throw new Error(`Failed to load user profile for ID ${userId}`, {
cause: dbError // original Error is preserved, accessible via .cause
});
}
try {
return JSON.parse(rawData);
} catch (parseError) {
throw new SyntaxError(`User profile data is malformed for ID ${userId}`, {
cause: parseError
});
}
}
// Traverse the error chain
function getErrorChain(error) {
const chain = [];
let current = error;
while (current) {
chain.push({
type: current.constructor.name,
message: current.message,
stack: current.stack
});
current = current.cause; // follow the chain
}
return chain;
}
// Usage
try {
await loadUserProfile(42);
} catch (err) {
console.log(getErrorChain(err));
// [{type:'Error', message:'Failed to load user profile for ID 42', ...},
// {type:'DatabaseError', message:'Connection refused', ...}]
}
An important property: error.cause is an ordinary, enumerable property. That means it does not automatically show up in JSON.stringify output (error objects are serialized as empty objects by default), but it is directly accessible via error.cause. For logging systems, you need a dedicated serialization function that explicitly traverses the cause chain, a pattern described in detail in section 5.
3. Custom error classes with cause support
Custom error classes are an indispensable pattern in larger JavaScript projects. They allow type-based error handling with instanceof and can carry application-specific properties such as HTTP status codes, error IDs or retry hints. With error.cause, custom errors can be designed to pass the cause parameter through to the parent constructor seamlessly, fully integrating them into the standardized error chain.
The pattern for custom error classes has become slightly simpler since ES2022. The super call with the options object passes cause directly to the built-in Error constructor. Custom properties are added in the constructor body. Important: the name property must be set explicitly, because the built-in Error.prototype.name property otherwise stays "Error" for all subclasses, which lets instanceof work correctly but makes stack traces and logs unreadable. A well-designed error hierarchy mirrors the layers of the application: network errors, database errors, validation errors and business logic errors as separate classes, all with error.cause support.
// Custom error hierarchy with full error.cause support
class AppError extends Error {
/**
* Base application error with cause chaining support.
* @param {string} message - Human-readable error description
* @param {object} options - Error options including cause and metadata
*/
constructor(message, { cause, code, retryable = false } = {}) {
super(message, { cause }); // pass cause to built-in Error
this.name = this.constructor.name; // 'AppError', 'NetworkError', etc.
this.code = code;
this.retryable = retryable;
// Preserve stack across transpilers
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
}
class NetworkError extends AppError {
constructor(message, { cause, statusCode, url } = {}) {
super(message, { cause, code: 'NETWORK_ERROR', retryable: statusCode >= 500 });
this.statusCode = statusCode;
this.url = url;
}
}
class ValidationError extends AppError {
constructor(message, { cause, field, value } = {}) {
super(message, { cause, code: 'VALIDATION_ERROR', retryable: false });
this.field = field;
this.value = value;
}
}
// Usage in a service layer
async function fetchProduct(id) {
let response;
try {
response = await fetch(`/api/products/${id}`);
} catch (fetchError) {
throw new NetworkError(`Could not reach product API for ID ${id}`, {
cause: fetchError,
url: `/api/products/${id}`
});
}
if (!response.ok) {
throw new NetworkError(`Product API returned error for ID ${id}`, {
statusCode: response.status,
url: response.url
});
}
}
4. error.cause in asynchronous code and promises
Asynchronous code in JavaScript significantly worsens the problem of lost error context. A rejected promise carries an error message, but the call stack at the time of the reject() call is often not very informative, especially in callback-based APIs or deeply nested promise chains. error.cause helps a lot here: when wrapping a rejected promise in a catch handler, you can pass the original error as cause and at the same time add a synchronous stack frame with current context.
With async/await the pattern is easy to implement: every async function that wants to pass errors on to its caller wraps its await calls in a try/catch and throws a new error with the original one as cause. This produces a cause chain that mirrors the nesting of the async functions. A particularly valuable pattern for API calls: network errors, HTTP status errors and parse errors are thrown as separate error types, each with its own cause, so the caller can react specifically to the error type while the original is preserved for logging.
5. Structured error logging with error chains
The full potential of error.cause unfolds when combined with structured logging. An error object cannot be serialized to JSON directly; JSON.stringify(new Error('test')) results in {}, because error properties are not enumerable. For a production logging system, you need a function that converts an error object, including its cause chain, into a clean JSON object. That object can then be processed by log aggregators like Elasticsearch, Datadog or Sentry without losing any information.
A complete error logging system serializes for every error in the cause chain: type, message, stack trace and custom properties like statusCode, code or field. The result is a JSON array that represents the full chain of causes and can be navigated in a log viewer. Error.cause thereby makes production debugging possible that was previously only achievable through elaborate log correlation or source code analysis. Sentry has supported cause chains directly since version 7 and displays them in the UI as a "Chained Exception".
// Serialize a full error.cause chain to a plain object for structured logging
function serializeError(error, depth = 0) {
if (!error || depth > 10) return null; // prevent infinite loops
const serialized = {
type: error.constructor?.name ?? 'Unknown',
message: error.message,
stack: error.stack?.split('\n').slice(0, 8).join('\n'), // trim stack
};
// Capture custom properties (statusCode, code, field, etc.)
const builtinKeys = new Set(['message', 'stack', 'name', 'cause']);
for (const key of Object.getOwnPropertyNames(error)) {
if (!builtinKeys.has(key)) {
serialized[key] = error[key];
}
}
if (error.cause) {
serialized.cause = serializeError(error.cause, depth + 1);
}
return serialized;
}
// Logger wrapper for production use
function logError(logger, context, error) {
logger.error({
context,
error: serializeError(error),
timestamp: new Date().toISOString(),
// Root cause extracted for quick filtering in log dashboards
rootCause: getRootCause(error)?.message,
});
}
function getRootCause(error) {
let current = error;
while (current.cause instanceof Error) {
current = current.cause;
}
return current;
}
6. error.cause in Node.js: fetch, fs and database errors
In Node.js, you also encounter error.cause in the built-in APIs themselves. The native fetch implementation in Node.js 18+ throws errors with a cause property on network problems, pointing to the underlying system error or the AbortSignal. The fs module also uses cause in certain error situations. Well-known database libraries such as Prisma and node-postgres support cause in newer versions for database-specific errors.
The practical benefit in Node.js applications is especially large for service-to-service communication. A request handler that internally calls five microservices can wrap every external error with the cause pattern: the HTTP handler throws a ServiceError with the network error as cause, and the network error carries the underlying ECONNREFUSED as cause. The application log then contains a full chain that immediately shows which service was unreachable, which code made the call, and what the system error was. This replaces the common situation where you have to manually infer the actual cause from a generic "Service unavailable" error.
7. Error chaining in production monitoring tools
error.cause is directly integrated into modern error monitoring tools. Sentry automatically recognizes the cause property and displays the error chain as a "Chained Exception": each error in the chain appears as its own panel with its stack trace. This makes root cause analysis considerably faster: instead of searching through the code to find which internal error led to a frontend error, you see the full chain directly in the monitoring dashboard. Datadog APM supports the same pattern via structured log fields.
The design of an error monitoring system that uses error.cause follows a clear principle: technical errors (network, database, parse errors) are caught close to their source, wrapped in a business error and passed upward. Only the outermost business errors are sent to monitoring, but with the full cause chain attached. This prevents duplicates (the same technical error does not appear multiple times) while still delivering complete context. Custom error classes with their own properties like userId, requestId or operationName enrich the monitoring data further.
8. Antipatterns in error handling without error.cause
Without error.cause, developers reach for workarounds that all bring their own problems. The most common antipattern: embedding the original error in the message string (new Error('Error: ' + originalError.message)). This destroys the structure: the type of the original error is lost, its stack trace is missing, and automated evaluation is barely possible. A second widespread antipattern is silently swallowing exceptions, catch (e) { /* ignore */ }, which shows up especially in cleanup code and systematically hides errors that would be valuable for debugging.
Another common problem: errors get logged multiple times. Every layer logs the error it catches before passing it on or wrapping it. The result is duplicates in the logs: the same error appears three or four times with slightly different context, which makes analysis harder. The correct pattern with error.cause: errors are only logged at the entry point (e.g. in the Express error handler or in the top-level catch), but with the full cause chain. Intermediate layers wrap and re-throw, but do not log themselves.
9. Error handling patterns compared
The evolution of JavaScript error handling clearly shows how error.cause improves on or replaces existing patterns.
| Pattern | Context preserved | Machine-readable | Tool support |
|---|---|---|---|
throw new Error(msg + orig.message) |
Message string only | No | None |
err.originalError = caught |
Yes, but non-standard | Only with custom code | Barely |
| Every layer logs itself | Yes, but duplicated | Difficult (duplicates) | Standard logging |
error.cause (ES2022) |
Full chain | Yes, standardized | Sentry, Node.js, browsers |
| Custom Error + cause hierarchy | Chain + business context | Yes, typed | Complete |
The pattern combining error.cause with custom error classes is today's recommended approach for professional JavaScript applications. It solves the old problems without workarounds, is directly supported by all modern runtimes and tools, and considerably improves the debuggability of production incidents. The implementation effort is small: it takes a small serialization function, an error class hierarchy and consistent application of the wrap-and-throw pattern.
Mironsoft
JavaScript development, error handling and production monitoring
Error handling that actually helps in production?
We implement structured error handling with error.cause, custom error hierarchies and integration into Sentry or Datadog, so production incidents can be analyzed in minutes instead of hours.
Error architecture
Custom error hierarchy with cause support and structured serialization
Monitoring integration
Sentry, Datadog or your own log aggregator with full error chains
Code review
Checking existing code for antipatterns and introducing error.cause patterns
10. Summary
JavaScript error.cause is a small syntactic addition with a large impact. It standardizes the wrapping of exceptions and makes error chains a first-class citizen of the language. Every layer of an application can enrich a technical error with business context without losing the original error. Custom error classes with cause support enable typed error handling and machine-readable error structures. Structured logging that traverses and serializes the cause chain turns production debugging into a solvable problem instead of a guessing game.
Support is widespread: all modern browsers, Node.js from version 16.9, and all relevant error monitoring tools. Migrating existing code is possible incrementally: new error throw sites use error.cause, old ones are updated as opportunities arise. The result is a codebase in which production incidents are traceable and debugging time drops drastically.
JavaScript error.cause: the essentials at a glance
Syntax
throw new Error('message', { cause: originalError }), the original is accessible via error.cause. Works with all error types.
Custom errors
Custom classes forward cause in the super() call. Set this.name explicitly. Add custom properties for business context.
Logging
Traverse and serialize the cause chain manually. Log only at the entry point, not in every layer, to prevent duplicates.
Tool support
Sentry displays cause chains as "Chained Exception". Node.js 18+ uses cause in native APIs. All modern browsers support ES2022.
11. FAQ: JavaScript error.cause
1What is error.cause?
new Error('msg', { cause: original }). The original error is fully preserved and accessible via error.cause.