Skip to content
academia.sh

Lesson 08 / 17

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.

Contents

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.

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.

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.

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.

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.

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.

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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close