Error Cause & AggregateError
A request fails three layers deep. By the time it surfaces to your API handler, the error says "Could not load profile" — and you have no idea why. The real failure was a socket timeout talking to Postgres, but that fact evaporated when a middle layer caught the error, threw a friendlier one, and dropped the original on the floor.
The usual patch is to smear the details into a string:
throw new Error(`Could not load profile: ${err.message}`);
That reads fine until you need the original error’s stack, its name, its custom fields, or its cause. A concatenated string is a tombstone. You get the last words and nothing else — no structure, no chain, no way to instanceof your way back to the network layer.
Two small additions to the language fix this. The cause option lets one error point at the error that triggered it, keeping the whole object alive. And AggregateError lets a single error carry a whole array of failures at once. Both are old news for the engine now — cause shipped across browsers in September 2021, AggregateError a year earlier — so you can reach for them without a polyfill or a build step. They are Baseline widely available.
The cause option
Every Error constructor (and every built-in subclass, and every class you extend from Error) accepts an optional second argument: an options object with a cause field.
throw new Error("Could not load profile", { cause: err });
Whatever you pass as cause gets attached to the new error as a .cause property. Usually that’s the error you just caught, but it can be any value — another error, a plain object, a string, a number.
async function loadProfile(id) {
try {
return await db.query("SELECT * FROM users WHERE id = $1", [id]);
} catch (err) {
// Wrap the low-level DB error in a domain-level one,
// but keep the original attached.
throw new Error(`Could not load profile ${id}`, { cause: err });
}
}
The wrapper carries a message that makes sense to its caller (“could not load profile”), while .cause still holds the raw err with its original message, stack, and type. Nothing is lost. The caller can log the friendly message and, when debugging, drill into .cause for the gritty details.
Why not just concatenate the message?
It’s worth being concrete about what the string approach throws away. Say the root cause is a custom error with useful fields:
class HttpError extends Error {
constructor(message, status) {
super(message);
this.name = "HttpError";
this.status = status;
}
}
Compare the two boundary strategies:
// (A) concatenate — lossy
catch (err) {
throw new Error(`Upload failed: ${err.message}`);
// err.status is gone. instanceof HttpError is impossible. stack is the wrapper's.
}
// (B) cause — lossless
catch (err) {
throw new Error("Upload failed", { cause: err });
// err is intact: err.cause.status === 503, err.cause instanceof HttpError, original stack.
}
With (B), code higher up can still ask real questions:
try {
await upload(file);
} catch (err) {
if (err.cause instanceof HttpError && err.cause.status === 503) {
return scheduleRetry(); // react to the *root* condition
}
logger.error(err); // and log the friendly wrapper
}
See the difference for yourself. Both boundaries below wrap the same HttpError, but only one can still answer questions about the root:
Walking the cause chain
Because each error can carry a cause, and that cause can itself carry a cause, errors form a singly linked list from the surface down to the root. Debuggers and good logging libraries walk this automatically. Walking it yourself is a plain loop:
function causeChain(err) {
const chain = [];
let current = err;
while (current != null) {
chain.push(current);
current = current.cause; // undefined on the last link → loop ends
}
return chain;
}
To print a readable trace from surface to root:
function explain(err) {
let current = err;
let depth = 0;
while (current != null) {
console.error(`${" ".repeat(depth)}${current.name}: ${current.message}`);
current = current.cause;
depth++;
}
}
Given a three-layer failure, that prints the story top to bottom:
Error: Could not render dashboard
Error: Could not load profile 42
TimeoutError: socket timeout after 5000ms
AggregateError: many failures, one error
cause chains a single line of failure. Sometimes you have the opposite problem: several independent things failed at once, and you want to report all of them, not just the first. That’s what AggregateError is for.
Its constructor is a little unusual because the errors come first:
new AggregateError(errors, message?, options?)
errors— an iterable (usually an array) of the failures you’re bundling.message— an optional description for the aggregate itself.options— an optional object that can carry its owncause, just like any error.
The resulting object has .name === "AggregateError", the message you gave it, and an .errors property: an array holding every error you passed in.
const failures = [
new Error("mirror-1 unreachable"),
new TypeError("mirror-2 returned HTML, not JSON"),
new RangeError("mirror-3 rate-limited"),
];
throw new AggregateError(failures, "All download mirrors failed");
Catch it and you can loop over the bundle:
try {
await downloadFromAnyMirror();
} catch (err) {
if (err instanceof AggregateError) {
console.error(err.message); // "All download mirrors failed"
for (const inner of err.errors) {
console.error(" •", inner.name, inner.message);
}
}
}
Tick the mirrors that failed, then bundle them. The .errors array holds exactly the ones you selected, each keeping its own type and message:
Where AggregateError comes from for free: Promise.any
You rarely have to construct an AggregateError by hand, because the standard library already hands you one. Promise.any takes several promises and resolves with the first one that fulfills. It only rejects if every promise rejects — and when that happens, it can’t pick a single reason, so it rejects with an AggregateError whose .errors array holds one rejection per input, in the original order.
try {
const fastest = await Promise.any([
fetch("https://a.example/data"),
fetch("https://b.example/data"),
fetch("https://c.example/data"),
]);
return await fastest.json();
} catch (err) {
// Only reached if ALL three fetches rejected.
console.error(err instanceof AggregateError); // true
console.error(err.message); // "All promises were rejected"
err.errors.forEach((e, i) => console.error(i, e.message));
}
This runs Promise.any for real (no network — the mirrors are plain resolved/rejected promises). Let at least one respond and you get its payload; knock them all out and you get the AggregateError:
Combining the two: aggregate with a cause
The tools compose. An AggregateError can itself carry a cause, and each error inside its .errors array can carry its own cause chain. A retry helper might bundle every attempt’s failure while still labelling the operation:
async function withRetries(task, times = 3) {
const attempts = [];
for (let i = 0; i < times; i++) {
try {
return await task();
} catch (err) {
attempts.push(err);
}
}
throw new AggregateError(
attempts,
`Failed after ${times} attempts`,
{ cause: attempts.at(-1) } // point at the final straw
);
}
Now a caller gets a single throw that answers three questions at once: what operation failed (message), every reason it failed (errors), and which failure was last (cause).
A realistic boundary
Here’s the whole idea in one place — a service call that fails at the network layer, gets wrapped at the data layer, and surfaces at the request handler with the chain intact.
class NetworkError extends Error {
constructor(message, options) {
super(message, options); // forward { cause } to the base constructor
this.name = "NetworkError";
}
}
async function fetchJson(url) {
let res;
try {
res = await fetch(url);
} catch (err) {
throw new NetworkError(`Request to ${url} failed`, { cause: err });
}
if (!res.ok) {
throw new NetworkError(`Bad status ${res.status} from ${url}`);
}
return res.json();
}
async function loadDashboard(userId) {
try {
return await fetchJson(`/api/users/${userId}/dashboard`);
} catch (err) {
throw new Error(`Could not render dashboard for ${userId}`, { cause: err });
}
}
// request handler
try {
const data = await loadDashboard(42);
} catch (err) {
explain(err); // prints the full chain, surface → root
respond(500, "Something went wrong");
}
Summary
new Error(message, { cause })attaches any value as.cause, so a boundary can throw a friendly, domain-level error while keeping the original object — its type, stack, and fields — fully intact. Baseline widely available since September 2021.- This beats concatenating
err.messageinto a string, which discards everything except the last words. Withcauseyou can stillinstanceofthe root, read its custom fields, and react to the real condition. .causeis non-enumerable, soJSON.stringifydrops it. Serialize it explicitly if you log errors as JSON.- Errors linked by
causeform a chain; walk it with awhile (current) { current = current.cause }loop, and cap the depth if the errors come from untrusted code. new AggregateError(errors, message, options)bundles many unrelated failures into one object with an.errorsarray. Baseline widely available since September 2020.Promise.anygives you anAggregateErrorfor free: it rejects only when every input rejects, collecting all the reasons into.errors. Contrast withPromise.all, which rejects with a single reason on the first failure.- The two compose: an
AggregateErrorcan carry its owncause, and each error inside.errorscan carry its own chain.