Lab notes · N°06 · · 3 min read
How to Fix forEach Ignoring await and Leaking Unhandled Rejections in Node
One 503 inside a forEach(async ...) loop killed the whole service: Node 15+ exits on unhandled rejections. for-of to sequence, map + allSettled to parallelize.
- Written by
- Kamol Nazarov
- Published
- Tags
- node · javascript · async
- Follow
- RSS feed
The note
At 3 AM on a Saturday the reminder service that texts customers "your car's ready" went down. One Twilio request came back 503, and the whole process followed it:
UnhandledPromiseRejection: TWILIO 503 for +1-718-555-0134
at .../notify.js:12:19
The line that did it looked harmless � customers.forEach(async (c) => { ... }). Everyone who's written JavaScript for a month has heard "forEach doesn't await". The part almost nobody connects is the second half: an async callback inside forEach produces promises nobody owns, so their rejections are unhandled � and since Node 15, an unhandled promise rejection kills the process instead of printing a warning. That's the foreach async unhandled promise rejection chain: one flaky third-party response, one dead service, one Saturday morning.
The "forEach doesn't wait" explainer is covered to death. This post is about the crash path and the two shapes that replace it.
The two things forEach does to async code
First, the famous one � it doesn't wait. The loop returns before the first await resolves, so anything after the forEach runs against zero completed work.
Second, the lethal one � it orphans every promise the callback creates. forEach ignores the return value. There's no aggregate promise to await or .catch. And the instinctive fix doesn't help:
try {
customers.forEach(async (c) => {
await sendSms(c); // throws here...
});
} catch (err) {
// ...never fires: forEach already returned successfully
}
The catch wraps a synchronous call that finished fine. The rejection is still falling with no parachute. Minimal reproduction on any current Node:
const ids = [1, 2,3];
ids.forEach(async (id) => {
const res = await fetch(`https://api.example.com/orders/${id}`);
if (!res.ok) throw new Error(`order ${id}: HTTP ${res.status}`);
});
console.log('loop done'); // prints... then the process exits non-zero
Pre-Node 15 that was a console warning and the script continued. Since Node 15 (October 2020), the default is --unhandled-rejections=throw: any rejection still unhandled when the microtask queue drains becomes a fatal error. So this isn't a style bug anymore � it's an unhandled promise rejection Node.js crash waiting for the first bad night.
Fix A: for...of when the loop should sequence
If the work should happen in order � or you just want the simplest possible correct shape � put an await inside a for of, not a forEach. The surrounding async function then really does wait, and try/catch actually catches:
for (const c of customers) {
try {
await sendSms(c);
} catch (err) {
logger.error({ to: c.phone, err }); // one bad number, process lives
}
}
Sequential means slower across many items, and for one outbound API that's usually a feature: you're not hammering it with fifty concurrent requests. The foreach vs for of async decision is mostly this: for of is the drop-in when the loop is the control flow.
Fix B: map + Promise.allSettled when parallel is the point
When the items are independent and you want them in flight together, map the list into promises � map returns them, so they're owned � and settle them explicitly:
const results = await Promise.allSettled(customers.map((c) => sendSms(c)));
const failures = results
.map((r, i) => ({ r, c: customers[i] }))
.filter(({ r }) => r.status === 'rejected');
for (const { r, c } of failures) {
logger.error({ to: c.phone, reason: r.reason }); // or enqueue a retry
}
Promise.all is the stricter sibling: one rejection rejects the whole call � catchable, at least, unlike forEach. allSettled is the right default when partial success is acceptable business behavior, which for "tell 50 customers their car is ready" it obviously is.
The decision tree
| Situation | Shape |
|---|---|
| Loop should run in order, handle each failure | for...of + try/catch |
| Items independent, report failures, keep the rest | map + Promise.allSettled |
| Items independent, all-or-nothing | map + Promise.all (catchable) |
| Many items, don't overwhelm the target | chunk the list, allSettled per chunk (or a concurrency limiter) |
| Genuinely fire-and-forget | even then: .catch(logger.error) on every promise you launch |
One safety net, not a fix
A process.on('unhandledRejection', ...) handler that logs before exit is worth having on a long-running service � as an alarm, not a cushion. If it ever fires, a promise escaped ownership somewhere and the loop that created it still needs the treatment above.
One sentence to keep: forEach was never async-aware, so every async callback you pass it becomes an unhandled rejection with a process kill attached � for...of to sequence, map + allSettled to parallelize, and never let a promise float.