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.

TimeoutError“socket timeout”stack, code: ETIMEDOUTcaught at the db boundary{ cause }Error“Could not load profile 42”.cause → TimeoutErrorre-thrown to the caller
A caught low-level error is re-thrown as a domain error. The original is not discarded — it is attached as .cause, so both survive the boundary.

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:

interactiveConcatenation loses the root; cause keeps it

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
Error“Could not render dashboard”surface.causeError“Could not load profile 42”boundary.causeTimeoutError“socket timeout after 5000ms”rootcurrent = current.cause until it is undefined
Errors linked by .cause form a chain from the surface error down to the root. Each layer adds context; none erases what came before.
Walking .cause from surface to root
1/8
Variables
current.name="Error"
current.message="Could not render dashboard"
Start at the surface error handed to the top-level handler.

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 own cause, 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:

interactiveBundle several failures into one AggregateError
Error“mirror-1 unreachable”TypeError“not JSON”RangeError“rate-limited”bundleAggregateError“All download mirrors failed”.errors = [Error, TypeError, RangeError].name = “AggregateError”Three unrelated failures collapse into one catchable object — none is dropped.
AggregateError wraps an iterable of failures into a single object whose .errors array holds them all, plus one message describing the group.

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));
}
promise Arejected: 500promise Brejected: timeoutpromise Crejected: DNSPromise.anyall rejectedAggregateError.errors = [A, B, C]If even one promise had fulfilled, Promise.any would have resolved with that value instead.The AggregateError only appears when there is nothing left to succeed.
Promise.any settles on the first fulfilment. If every input rejects instead, all the rejection reasons are collected into one AggregateError.errors array.

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:

interactivePromise.any: winner, or an AggregateError of every reason

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, &#123; cause &#125;) 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.message into a string, which discards everything except the last words. With cause you can still instanceof the root, read its custom fields, and react to the real condition.
  • .cause is non-enumerable, so JSON.stringify drops it. Serialize it explicitly if you log errors as JSON.
  • Errors linked by cause form a chain; walk it with a while (current) &#123; current = current.cause &#125; 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 .errors array. Baseline widely available since September 2020.
  • Promise.any gives you an AggregateError for free: it rejects only when every input rejects, collecting all the reasons into .errors. Contrast with Promise.all, which rejects with a single reason on the first failure.
  • The two compose: an AggregateError can carry its own cause, and each error inside .errors can carry its own chain.