for await over an array of promises crashes Node on a fast failure
published
TL;DR
for await (const r of promises) only attaches a handler to the promise it is currently waiting on. Any promise further down the array that rejects before the loop gets there is, for that moment, an unhandled rejection, and since Node 15 that ends the process with exit code 1 — the try/catch around the loop never sees it. The same happens with a plain for...of plus await and with Array.fromAsync(). Use Promise.all() or Promise.allSettled(), or attach a handler to every promise the moment you create it.
The problem
The pattern is attractive because it seems to give you two things at once: every request runs concurrently, and you still handle the results in order, rendering the first one while the rest are loading.
const pages = urls.map((url) => fetch(url).then((res) => res.json()))
try {
for await (const page of pages) {
render(page)
}
} catch (err) {
showError(err)
}
It works in development, where every request succeeds. Here is the same shape with timers standing in for requests, where the second task fails after 10 ms and the first succeeds after 100 ms:
// crash.mjs
const task = (name, ms, fail = false) =>
new Promise((resolve, reject) =>
setTimeout(() => (fail ? reject(new Error(`${name} failed`)) : resolve(name)), ms),
)
const tasks = [task('users', 100), task('orders', 10, true), task('stock', 50)]
try {
for await (const result of tasks) {
console.log('got', result)
}
} catch (err) {
console.log('caught:', err.message)
}
console.log('done')
On Node v26.2.0 (file paths shortened, source excerpt trimmed):
Error: orders failed
at Timeout._onTimeout (file:///…/crash.mjs:4:37)
at listOnTimeout (node:internal/timers:605:17)
at process.processTimers (node:internal/timers:541:7)
Node.js v26.2.0
Exit code 1. No got users, no caught:, no done. The process died 10 ms in, while the loop was still waiting for users.
The order of the failures matters, which makes this look flaky. If the first task fails instead, the loop catches it, and the next failure kills the process anyway after your error handling has already run:
// after-catch.mjs — same file, different tasks
const tasks = [task('users', 10, true), task('orders', 50, true), task('stock', 100)]
caught: users failed
done
Error: orders failed
at Timeout._onTimeout (file:///…/after-catch.mjs:4:37)
at listOnTimeout (node:internal/timers:605:17)
at process.processTimers (node:internal/timers:541:7)
Node.js v26.2.0
Exit code 1, after done was printed. In a server, that is a request handler that returned an error page and then took the whole process down 40 ms later.
Why it happens
A promise counts as unhandled if it rejects and still has no handler once the microtask queue drains. for await...of over an array does not subscribe to the whole array: the language wraps the sync iterator so that each next() awaits one value at a time (CreateAsyncFromSyncIterator in the spec). Until the loop reaches a promise (while it awaits tasks[0], and while the loop body runs) tasks[1] and tasks[2] have no handlers. If one of them rejects in that window, the rejection is reported as unhandled. The loop would have attached a handler later, but Node has already decided what to do with it.
What Node does is set by --unhandled-rejections. The default since v15.0.0 is throw: emit unhandledRejection, and if no listener is set, “raise the unhandled rejection as an uncaught exception”. An uncaught exception exits the process.
In real code the window is wide, because failures are usually faster than successes. A refused connection, a DNS failure or an error page that res.json() cannot parse comes back in milliseconds, while the slow request ahead of it in the array is still streaming. The bug shows up exactly when something goes wrong, which is also when you most need the error handling to run.
The loop is not the only shape with this problem. Anything that creates the promises first and awaits them one at a time behaves the same:
| Shape | Result when orders fails first (Node v26.2.0) |
|---|---|
for await (const r of tasks) | process exits, code 1 |
for (const p of tasks) await p | process exits, code 1 |
await Array.fromAsync(tasks) | process exits, code 1 |
await Promise.all(tasks) | caught: orders failed, exit 0 |
await Promise.allSettled(tasks) | all three outcomes reported, exit 0 |
Array.fromAsync() is in the list because, as MDN puts it, it “awaits each value yielded from the object sequentially”, while Promise.all() “awaits all values concurrently” — and attaches its handlers to every input up front.
MDN’s own example of for await...of over a sync iterable is safe, and the reason is useful: it uses a generator, so each promise is created when the loop asks for it. The trouble comes from creating the promises ahead of the loop, not from the loop itself.
What to do
Each fix below replaces the loop inside crash.mjs’s try block; the catch and the final console.log('done') stay.
If one failure should fail the batch, use Promise.all(). It attaches a handler to every promise immediately, so nothing is ever unhandled:
const results = await Promise.all(tasks)
for (const r of results) console.log('got', r)
caught: orders failed
done
You lose the “render the first one while the rest load” behaviour, and later rejections are dropped without any report. That second point has its own trap, covered in Promise.all does not cancel the others.
If you need every outcome, use Promise.allSettled():
for (const r of await Promise.allSettled(tasks)) {
console.log(r.status, r.status === 'fulfilled' ? r.value : r.reason.message)
}
fulfilled users
rejected orders failed
fulfilled stock
done
If you really want in-order results as they arrive, mark every promise as handled when you create it, then keep the loop:
const tasks = [task('users', 100), task('orders', 10, true), task('stock', 50)]
for (const p of tasks) p.catch(() => {})
for await (const result of tasks) console.log('got', result)
got users
caught: orders failed
done
p.catch(() => {}) returns a new promise and leaves p alone, so the loop still receives the rejection when it reaches orders. All it changes is that p now has a handler, so Node no longer treats the early rejection as unhandled. The loop still stops at the first failure in array order, and anything that fails after the loop has exited is swallowed. If those later failures matter, log them in that handler instead of discarding them.
Or do not create the promises early. for (const url of urls) render(await fetch(url).then((res) => res.json())) has no unhandled window because each request starts when the previous one has finished. It is also sequential, which is usually the thing you were trying to avoid.
Caveats
--unhandled-rejections=warnhides the crash, not the bug. With it, the first example printsUnhandledPromiseRejectionWarning, thengot users,caught: orders failed,done, and finallyPromiseRejectionHandledWarning: Promise rejection was handled asynchronously, and exits 0. Changing a process-wide default to accommodate one loop is the wrong trade.- A global
process.on('unhandledRejection')listener also stops the exit, and it also hides every real bug of this kind across the codebase. Fix the loop instead. - Browsers do not exit. An unhandled rejection there fires the
unhandledrejectionevent and logs an error, so the same code looks fine in the front end and kills the process on the server. - Timing decides whether you see it. A rejection is safe only if the loop is already awaiting that promise when it rejects. Earlier promises having settled is not enough: if the loop body awaits (a render, a DB write), the next promise has no handler for that whole time. That is why this passes tests that only mock successes.
- Everything above was run on Node v26.2.0. The
throwdefault has been stable since v15.0.0.