Here is a function that looks correct and will sail through any review:
const [user, orders, prefs] = await Promise.all([
fetch(`/api/user/${id}`).then((r) => r.json()),
fetch(`/api/orders/${id}`).then((r) => r.json()),
fetch(`/api/prefs/${id}`).then((r) => r.json()),
]);
If /api/orders rejects, Promise.all rejects, the await throws, and you handle it. Clean. The bug is in the word rejects. There is one sentence that explains a whole category of production weirdness:
When
Promise.allrejects, it rejects. It does not cancel the tasks it was waiting on.
The other two fetches keep going. They resolve into the void. They hold their browser connection slots — and on the server, a Promise.all over database queries holds pool connections — until they finish on their own. Nobody is awaiting them anymore, but they don't know that. I picked this up from Alex's note on async lifetimes, and the reframing is the whole point: rejection is not cancellation. A promise is a notification that something finished, not a handle you can pull on to make it stop.
Once you accept that, cancellation stops being an afterthought and becomes part of an operation's lifetime — and the modern toolkit stops looking like trivia.
The handle you actually want is AbortController. Share one signal across the fan-out, and when the first task fails, abort the rest instead of orphaning them:
const ctrl = new AbortController();
// abort on first failure OR after 5s, whichever fires first
const signal = AbortSignal.any([ctrl.signal, AbortSignal.timeout(5000)]);
const get = (path) =>
fetch(path, { signal }).then((r) => r.json());
try {
return await Promise.all([
get(`/api/user/${id}`),
get(`/api/orders/${id}`),
get(`/api/prefs/${id}`),
]);
} catch (err) {
ctrl.abort(err); // tell the survivors to stop
throw err;
}
AbortSignal.any([...]) composes signals into one that fires when any input does, and AbortSignal.timeout(ms) gives you the timeout leg for free. Both are Baseline since 2024, so you can lean on them in browsers and current Node without ceremony.
The try/catch still nags, though — you have to remember to abort. The structural version pushes teardown into the type itself:
async function scope() {
await using ctrl = {
abort: new AbortController(),
[Symbol.asyncDispose]: async () => ctrl.abort.abort(),
};
// ...use ctrl.abort.signal...
} // disposer runs on return, throw, or early return — always
await using calls [Symbol.asyncDispose]() on every exit from the block, so cleanup is no longer a finally you might forget. The honest caveat: as of 2026 this one is uneven. It shipped in Chromium 134 / V8 13.8 and Node 24 graduated Symbol.asyncDispose from experimental, but it isn't Baseline — Safari has no native support yet — so feature-detect or transpile (TypeScript and Babel both lower it). AbortSignal is the part you can ship everywhere today; await using is the ergonomic upgrade for where it runs.
None of this is about cleaning up your effects more diligently. It's one mental swap: a promise tells you something ended; a signal lets you end it. Treat cancellation as a first-class lifetime concern and the leak that passed review stops being writable in the first place.