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
awaitrejects, 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; acatchplaced at the end of the chain covers every step before it.- Catching a promise in its own
tryblock requires awaiting it before returning. allcarries only the first rejection;allSettledis used when every error is needed.- The
causeoption 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.