---
title: 'Asynchronous Error Handling'
source: 'https://academia.sh/en/courses/asynchronous-javascript/asynchronous-error-handling'
course: 'Asynchronous JavaScript and the Runtime'
language: en
updated: '2026-08-17T18:09:44+00:00'
license: 'CC BY-SA 4.0'
---

# Asynchronous Error Handling

Rejection propagating through the chain, the points where try/catch and catch syntax diverge, the loss of error information, the cause chain, and tracking unhandled rejections.

The previous lesson deliberately left the error path aside. But in asynchronous code,
an error behaves differently than in synchronous code: since the stack has long since
emptied, there is no caller left for the error to throw "upward" to. A rejection is
carried not through the stack but through the **promise itself**.

This lesson establishes how rejection propagates, which syntax catches which error,
and what a rejection that no one handles actually is. The measurement stream becomes
resilient to faulty stations here.

## Rejection Is Caught with `try`/`catch`

If a promise awaited with `await` rejects, it is treated as if an error were thrown at
the point of waiting. This is the single rule that makes the `try`/`catch`/`finally`
structure reusable in asynchronous code.

A second faulty station was added to the source table; `E5` rejects with a shorter
delay. This will be used in examples where the order of errors matters.

```js
const SOURCE = {
  A1: { delay: 30, value: 21.4 },
  B2: { delay: 10, value: 19.8 },
  C3: { delay: 20, value: 23.1 },
  D4: { delay: 15, error: "sensor fault" },
  E5: { delay: 5, error: "no connection" },
};

function requestMeasurement(station) {
  return new Promise((resolve, reject) => {
    const record = SOURCE[station];
    setTimeout(() => {
      if (record.error) reject(new Error(`${station}: ${record.error}`));
      else resolve({ station, value: record.value });
    }, record.delay);
  });
}

try {
  const measurement = await requestMeasurement("D4");
  console.log("this line does not run:", measurement.value);
} catch (error) {
  console.log("caught:", error.message);
} finally {
  console.log("finally: resource released");
}
```

```
caught: D4: sensor fault
finally: resource released
```

The `finally` block keeps its synchronous-code meaning: it runs no matter what the
result is, and it does not change the result. Cleanup work — closing an opened
resource, canceling a set-up timer — is written here.

## `then`'s Second Parameter Is Not the Same as `catch`

In chain syntax, an error handler can be placed in two spots, and the two catch
different things. The second callback given as `then(onSuccess, onError)` handles only
the rejection of the promise **before it**; it does not handle an error thrown by the
success callback in that same `then` call.

```js
Promise.resolve("raw value")
  .then(
    () => {
      throw new Error("error in processing step");
    },
    (error) => console.log("this callback does not run:", error.message),
  )
  .catch((error) => console.log("caught at the end of the chain:", error.message));
```

```
caught at the end of the chain: error in processing step
```

The rule is: an error handler has to come **after** the step where the error occurs.
This is why the default choice is putting a single `catch` at the end of the chain; the
two-parameter form only makes sense when the intent is "let someone else handle this
step's own error."

## Returning by Awaiting

In an async function, `return requestMeasurement(...)` and
`return await requestMeasurement(...)` give the same result in most contexts. They do
**not** inside a `try` block.

```js
const SOURCE = {
  D4: { delay: 15, error: "sensor fault" },
};

function requestMeasurement(station) {
  return new Promise((resolve, reject) => {
    const record = SOURCE[station];
    setTimeout(() => {
      if (record.error) reject(new Error(`${station}: ${record.error}`));
      else resolve({ station, value: record.value });
    }, record.delay);
  });
}

async function returnWithoutAwait(station) {
  try {
    return requestMeasurement(station);
  } catch (error) {
    return "catch ran";
  }
}

async function returnWithAwait(station) {
  try {
    return await requestMeasurement(station);
  } catch (error) {
    return "catch ran";
  }
}

console.log("with await:", await returnWithAwait("D4"));
try {
  await returnWithoutAwait("D4");
} catch (error) {
  console.log("without await: error leaked out —", error.message);
}
```

```
with await: catch ran
without await: error leaked out — D4: sensor fault
```

With a promise returned without awaiting, the function returns before the promise
settles; the `try` block has already closed by then, and the rejection passes to the
caller. The rule: **if you want to catch a promise in your own `try` block, you have
to await it before returning.**

## The Loss of Error Information

Combinators carry error information differently. `Promise.all` gives only the
**first** rejection; the rest disappear silently.

```js
const SOURCE = {
  D4: { delay: 15, error: "sensor fault" },
  E5: { delay: 5, error: "no connection" },
};

function requestMeasurement(station) {
  return new Promise((resolve, reject) => {
    const record = SOURCE[station];
    setTimeout(() => {
      if (record.error) reject(new Error(`${station}: ${record.error}`));
      else resolve({ station, value: record.value });
    }, record.delay);
  });
}

try {
  await Promise.all([requestMeasurement("D4"), requestMeasurement("E5")]);
} catch (error) {
  console.log("all gives only the first error:", error.message);
}

const results = await Promise.allSettled([requestMeasurement("D4"), requestMeasurement("E5")]);
const errors = results.filter((r) => r.status === "rejected").map((r) => r.reason.message);
console.log("allSettled gives all of them:", errors.join(" | "));
```

```
all gives only the first error: E5: no connection
allSettled gives all of them: D4: sensor fault | E5: no connection
```

Both stations were faulty; `all` reported only whichever rejected earlier. In cases
where every error is needed for diagnosis — for example, logging which stations went
down — `allSettled` should be used.

## The Cause Chain

Catching an error and throwing a more meaningful one in its place is common; but if
the root cause is lost, diagnosis gets harder. An error object's `cause` option
attaches the lower-level error to the higher-level one.

```js
const SOURCE = {
  D4: { delay: 15, error: "sensor fault" },
};

function requestMeasurement(station) {
  return new Promise((resolve, reject) => {
    const record = SOURCE[station];
    setTimeout(() => {
      if (record.error) reject(new Error(`${station}: ${record.error}`));
      else resolve({ station, value: record.value });
    }, record.delay);
  });
}

async function readMeasurement(station) {
  try {
    return await requestMeasurement(station);
  } catch (error) {
    throw new Error(`could not read station: ${station}`, { cause: error });
  }
}

try {
  await readMeasurement("D4");
} catch (error) {
  console.log("top-level error:", error.message);
  console.log("root cause:", error.cause.message);
}
```

```
top-level error: could not read station: D4
root cause: D4: sensor fault
```

This pattern prevents information loss while adding context between layers: the upper
layer says "which station," the lower layer says "why," and both are carried in a
single object.

## Unhandled Rejections

If a promise rejects and no error handler is ever attached to it, the result is an
error caught nowhere. The runtime tracks this and reports it.

```js
const SOURCE = {
  D4: { delay: 15, error: "sensor fault" },
};

function requestMeasurement(station) {
  return new Promise((resolve, reject) => {
    const record = SOURCE[station];
    setTimeout(() => {
      if (record.error) reject(new Error(`${station}: ${record.error}`));
      else resolve({ station, value: record.value });
    }, record.delay);
  });
}

process.on("unhandledRejection", (reason) => {
  console.log("1 — unhandled rejection reported:", reason.message);
});

process.on("rejectionHandled", () => {
  console.log("3 — rejection handled later");
});

const promise = requestMeasurement("D4");
setTimeout(() => promise.catch((error) => console.log("2 — late catch:", error.message)), 50);
```

```
1 — unhandled rejection reported: D4: sensor fault
2 — late catch: D4: sensor fault
3 — rejection handled later
```

A handler was attached later, and the error really was caught; but by then the
runtime had already made its "unhandled rejection" report. The report is produced for
rejections that are still without a handler once the microtask queue drains.

This has two practical consequences. First, the error handler should be attached
**where you create** the promise; leaving it for later produces a false alarm. Second,
if no listener is registered at all, the runtime applies its default behavior: in a
server environment, a diagnostic message is printed and the process exits with a
nonzero code. This is why an unhandled rejection is not a warning to log and move
past, but a **defect to fix**.

## Resilient Measurement Aggregation

When this lesson's tools combine, the fault-resilient form of the measurement stream
emerges: every station produces either a result or a failure with a known reason, no
error disappears silently, and aggregation does not stop because of a single fault.
The one remaining gap is a station that never responds at all; neither `catch` nor
`allSettled` solves that, because there is no rejected promise in the picture. That
gap will be closed by the timeout mechanism.

## Summary

- Rejection is carried through the promise, not the stack; when a promise awaited
  with `await` rejects, it is treated as if an error were thrown at the point of
  waiting.
- `then`'s second parameter handles only the previous step's error; a `catch` placed
  at the end of the chain covers every step before it.
- Catching a promise in its own `try` block requires awaiting it before returning.
- `all` carries only the first rejection; `allSettled` is used when every error is
  needed.
- The `cause` option makes wrapping an error possible while preserving the root
  cause.
- An unhandled rejection is one that stays without a handler once the microtask
  queue drains, and it is reported by the runtime.

## Next Step

Up to this point, every measurement was a one-time result: request, wait, get.
Measurement stations, though, produce data continuously; their result is represented
not by a single promise but by an open-ended **sequence**. The next lesson takes up
that sequence: the asynchronous iterator protocol, the `for await` loop, asynchronous
generators, and the backpressure problem that arises when the consumer is slower than
the producer.
