Asynchrony
async/await and Propagating Errors in Practice
What async functions really return, how await suspends without blocking, the rejections try/catch misses, and why loop-and-await costs you time.
You replace a chain with async/await, the code gets shorter, and
one bug disappears: the missing return from the previous lesson cannot happen when
every wait is written as await. Two new bugs arrive instead. The first is a
try block around a call you forgot to await, which catches nothing at all. The
second is a loop with await inside it, which is correct, readable, and slower than
the code it replaced by a factor equal to the number of iterations.
This lesson is where asynchrony turns from syntax into performance and correctness. The examples are complete files, run on Node 24.11.1, and the timing numbers are measured rather than estimated.
Every async function returns a promise, including the ones that throw
There is no syntax for declaring that a function is asynchronous other than
async, and its meaning is narrow: the function returns a promise, always. A
return statement becomes a fulfilment, and a throw becomes a
rejection.
async function loadUser(id) {
return { id, name: `user-${id}` };
}
const p = loadUser(1);
console.log("constructor:", p.constructor.name, "| is a promise:", p instanceof Promise);
console.log("value:", await p);
async function fail() {
throw new Error("validation failed");
}
const rejected = fail();
console.log("rejected is a promise:", rejected instanceof Promise);
console.log("caught:", await rejected.catch((e) => e.message));
constructor: Promise | is a promise: true
value: { id: 1, name: 'user-1' }
rejected is a promise: true
caught: validation failed
Returning a promise from an async function does not nest one promise inside another. The
returned promise is adopted, so the outer function settles when the inner one does, with the same
value or the same reason. That makes the keyword transitive in a useful way: any function up the
call stack that awaits gets the real outcome, and none of them needs a return
statement to keep the chain intact.
The difference that matters at the call site is what happens when nobody awaits.
loadUser(1) called as a statement returns a fulfilled promise that is dropped, which
is harmless. fail() called as a statement returns a rejected promise that nobody
handles, which is an unhandled rejection. A plain function that throws gives you a stack pointing
at the call; the async version gives you a stack pointing at the function and an
unhandledRejection event on the process.
await suspends the function without blocking the event loop
A useful way to read await expression is "evaluate the expression, hand control
back to whoever called me, and resume here with the settled value when the promise settles". The
function's execution context is kept, everything else in the runtime keeps running, and the
resume happens as a microtask.
async function outer() {
console.log("1 outer starts");
const value = await Promise.resolve("resolved");
console.log("4 after await:", value);
}
console.log("0 before call");
outer();
console.log("2 outer suspended, the call returned");
console.log("3 still the same tick");
0 before call
1 outer starts
2 outer suspended, the call returned
3 still the same tick
4 after await: resolved
The body runs synchronously up to the first await. After that the caller
continues, which is why 2 and 3 come before 4. Nothing was
blocked, and that is the point of the keyword: it reads like synchronous code while leaving the
runtime free.
"Non-blocking" is a claim about await, not about your function. A synchronous
loop inside an async function blocks exactly as it would anywhere else, because it never yields:
const sleepBlocking = (ms) => {
const end = Date.now() + ms;
while (Date.now() < end) {}
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let t = Date.now();
setTimeout(() => console.log(`timer fired after ${Date.now() - t}ms`), 10);
sleepBlocking(100);
console.log(`blocking loop finished at ${Date.now() - t}ms`);
await sleep(120);
t = Date.now();
setTimeout(() => console.log(`timer fired after ${Date.now() - t}ms`), 10);
await sleep(100);
console.log(`await returned at ${Date.now() - t}ms`);
timer fired after 100ms
blocking loop finished at 100ms
timer fired after 10ms
await returned at 100ms
In the first half a ten-millisecond timer waits the full hundred milliseconds, because the
loop owns the thread. In the second half the timer fires on time while the awaiting function is
suspended. A CPU-bound loop inside an async function is the most common way a Node service
develops a mysterious latency spike: the code looks asynchronous and behaves like a
sleep call from the event loop's perspective.
try/catch reaches an awaited rejection and nothing else
Inside an async function, await turns a rejection into a throw at that line, so
ordinary try/catch works and finally works with it. The
catch does not reach anything that is not on that line's await path:
process.on("uncaughtException", (err) => console.log("uncaughtException:", err.message));
// 1. awaited: caught
try {
await Promise.reject(new Error("rejected while awaited"));
} catch (err) {
console.log("1 caught:", err.message);
}
// 2. not awaited: the try block finished long before this rejects
try {
Promise.reject(new Error("rejected in the background"));
} catch (err) {
console.log("2 caught:", err.message);
}
await new Promise((r) => setImmediate(r));
// 3. a throw from inside a callback handed to a timer
try {
setTimeout(() => {
throw new Error("thrown inside a timer callback");
}, 5);
} catch (err) {
console.log("3 caught:", err.message);
}
await new Promise((r) => setTimeout(r, 20));
// 4. a synchronous throw in an async function you did not await
async function validate(row) {
if (!row.id) throw new Error("row has no id");
}
try {
validate({});
} catch (err) {
console.log("4 caught:", err.message);
}
await new Promise((r) => setImmediate(r));
1 caught: rejected while awaited
uncaughtException: rejected in the background
uncaughtException: thrown inside a timer callback
uncaughtException: row has no id
Case two is the one people defend in code review, because "the rejection happens in this
tick" feels true. It is not: the promise is already rejected when the try block
ends, but the handler that would throw into the enclosing scope runs on a microtask after the
block has been left. Case four is the subtle one, because validate genuinely throws
before its first await. The throw happens inside the async function, so it is
converted to a rejection of the promise it returned, and that promise is the thing nobody
awaited.
Case three has nothing to do with promises. It is the callback problem from the previous
lesson, and the fix is to make the callback async and catch inside it, or to convert the API to a
promise with node:util's promisify for Node-style callbacks and then
await it. A timer callback that throws has no promise and no caller to reject.
Loop and await runs one request at a time, and it shows
Sequential awaits inside a loop are correct, and when each step depends on the previous one they are the only option. When the steps are independent, the loop serialises them and the elapsed time becomes the sum of the latencies. Four requests to a service that takes about 220 milliseconds each:
const wait = (ms, label) => new Promise((resolve) => setTimeout(() => resolve(label), ms));
const DELAYS = [220, 240, 210, 230];
async function fetchSequential(ids) {
const results = [];
for (const id of ids) {
results.push(await wait(id, `row-${id}`));
}
return results;
}
async function fetchConcurrent(ids) {
return Promise.all(ids.map((id) => wait(id, `row-${id}`)));
}
async function fetchInBatches(ids, size) {
const out = [];
for (let i = 0; i < ids.length; i += size) {
out.push(...(await Promise.all(ids.slice(i, i + size).map((id) => wait(id, `row-${id}`)))));
}
return out;
}
for (const [name, fn] of [
["sequential", fetchSequential],
["concurrent", fetchConcurrent],
["batches of 2", (ids) => fetchInBatches(ids, 2)],
]) {
const started = Date.now();
const rows = await fn(DELAYS);
console.log(`${name.padEnd(13)} ${String(Date.now() - started).padStart(4)}ms ${rows.join(" ")}`);
}
sequential 905ms row-220 row-240 row-210 row-230
concurrent 241ms row-220 row-240 row-210 row-230
batches of 2 471ms row-220 row-240 row-210 row-230
The three lines answer a question the sequential loop hides: those four awaits cost 905
milliseconds. Concurrency brings it to 241, which is the slowest single request, and the
concurrent version preserves input order because Promise.all assembles its array by
position rather than by completion. Batching is the middle option, and it is usually the right
one for a real service: 471 milliseconds is slower than unbounded concurrency, and it caps how
many requests you have in flight at once.
That cap is not a formality. Promise.all over ten thousand ids opens ten thousand
connections, exhausts the socket pool, and turns a working service into a source of timeouts for
everyone else. The rule I use is a modest fixed batch size, chosen from what the downstream
service can take, with the loop above as the shape. If you know the database driver or HTTP
client has a pool limit, the batch size should be at or below it.
One more surprise hides in the concurrent line: Promise.all rejects on the first
failure and leaves the others running, so a concurrent fan-out that fails early still finishes
the work it started. The next lesson covers cutting that work short.
A rejected promise nobody awaited is a report, not an error
Calling an async function without await is called fire and forget, and it is
reasonable when the work genuinely does not matter. The failure mode is that it looks identical
to work that does:
process.on("unhandledRejection", (reason) => console.log("unhandledRejection:", reason.message));
async function syncInventory(sku) {
if (!sku) throw new Error("sku is required");
return { sku, synced: true };
}
try {
syncInventory("");
} catch (err) {
console.log("never runs:", err.message);
}
async function reindex(rows) {
for (const row of rows) {
syncInventory(row.sku);
}
return rows.length;
}
console.log("reindex claims:", await reindex([{ sku: "a" }, { sku: "" }, { sku: "c" }]));
unhandledRejection: sku is required
reindex claims: 3
unhandledRejection: sku is required
reindex reports that it processed three rows. One of them failed, the failure
exists only as a process-level event, and the caller that wanted a count got a number it can
trust even less than it realises. In a browser the same code produces a console warning and keeps
going, which is worse.
The fix is to make the decision explicit rather than accidental. Either await the call, or handle the rejection where you drop it:
// await it, and let the loop fail as a whole
for (const row of rows) {
await syncInventory(row.sku);
}
// or collect the work, keep the order, and fail together
await Promise.all(rows.map((row) => syncInventory(row.sku)));
// or drop it deliberately, with the rejection handled and named
syncInventory(row.sku).catch((err) => report("inventory sync failed", { sku: row.sku, err }));
The third form is the only honest fire and forget: it states in the code that nobody is
waiting and that the failure is somebody's problem. Note what is not on the list: an empty
catch. That converts a failure into silence, and the next person to debug it starts
from a log line that says nothing.
This function fetches a dashboard for a list of accounts. Each logical fetch is independent, and the function is correct.
async function dashboard(accounts) {
const rows = [];
for (const account of accounts) {
rows.push(await fetchAccount(account.id));
}
return rows;
}
Assume fetchAccount takes about 200 milliseconds and that the service
tolerates at most 3 concurrent requests. Rewrite the function to respect that limit, and
measure it against the sequential version with 9 accounts. Predict both numbers before you
run anything.
Then answer two questions. If fetchAccount for the fourth account rejects,
what does your version do with the other eight requests that are already in flight? And if
the dashboard should show the accounts it could load instead of failing as a whole, which
line changes?
Show the solution and the reasoning
The shape is a loop over chunks of at most three, with the concurrency inside the chunk:
async function dashboard(accounts, { limit = 3 } = {}) {
const rows = [];
for (let i = 0; i < accounts.length; i += limit) {
const chunk = accounts.slice(i, i + limit);
rows.push(...(await Promise.all(chunk.map((a) => fetchAccount(a.id)))));
}
return rows;
}
With nine accounts at 200 milliseconds each, the sequential version takes about 1,800 milliseconds: nine requests, one after another. The batched version takes about 600 milliseconds, three batches of three. If you measured it with the previous lesson's harness, the numbers come out close to those multiples, and the exact figure moves by a few milliseconds per batch.
rows.push(...) is what keeps the order stable. Each chunk resolves in input
order, and the chunks are appended in the order they were cut, so the final array matches the
input array even though the requests inside a chunk finish in whatever order the service
answers them. If order did not matter, rows.push of the whole array would be
shorter, and I would still write the ordered version, because "the order is whatever the
network did" is a bug that shows up in a table months later.
On the failure question: a rejection inside a chunk rejects that Promise.all,
which throws out of dashboard and stops the loop. The other requests in the same
chunk are already in flight and keep running to completion, because nothing cancels them, and
the later chunks are never started. So a failure in the fourth account leaves the fifth and
sixth finishing in the background and the seventh through ninth never sent.
To report the accounts that loaded, swap Promise.all for
Promise.allSettled and fold the results:
const results = await Promise.allSettled(chunk.map((a) => fetchAccount(a.id)));
for (const result of results) {
if (result.status === "fulfilled") rows.push(result.value);
else failures.push(result.reason);
}
That is the right change when partial data is genuinely useful, which it is for a dashboard. The trade-off is that the caller must now be told that the dashboard is incomplete, otherwise a partial page looks like a complete one, which is a failure that is harder to notice than an exception.
For the limit itself, one alternative is worth the extra code. A fixed batch size makes every caller wait for the slowest request in the batch before starting the next. A worker-pool keeps three requests in flight continuously and finishes sooner when latencies vary, at the cost of about twenty more lines. Start with batching; move to a pool when the measurement says the gaps cost you something.
Progress
Mark this lesson done and it stays marked in this browser.