By Ryan Calloway. Updated May 2026.
Promise.all, allSettled, race, any, and the new Promise.try / Promise.withResolversawait inside forEach, and module-blocking top-level await in request pathstypescript-eslint‘s no-floating-promises rule and use AbortSignal.timeout() instead of hand-rolled Promise.race timeoutsAn async function you forget to await is a Promise nobody is listening to. In Node 15+ that became a process-killing event by default, the v15 release notes made it official, and the typescript-eslint no-floating-promises rule exists because every team rediscovers the bug the same way. async/await and raw Promises are two surfaces over the same engine. The mental shortcut “just use async/await always” is mostly right and wrong in five specific places. This guide is the side-by-side, current to Node 22 LTS and ES2024-2025.
Quick answer: async/await is syntactic sugar over Promises and compiles to identical V8 bytecode in 2026 — there is no perf difference. Use async/await for sequential reads and try/catch for errors. Reach for Promise.all, allSettled, race, any when you need concurrency, Promise.try (ES2024) when you need to lift a maybe-sync function into a promise, and Promise.withResolvers (ES2024) when you need to resolve a promise from outside its executor. The rest explains why and shows the code.
The mental model
A Promise is an object representing a pending result in one of three states: pending, fulfilled (with a value), or rejected (with a reason). .then, await, and the Promise.* static methods are all ways to read from that object. async functions are functions whose return value is automatically wrapped in a Promise; await unwraps a Promise back to a value (or throws its rejection reason).
async function f() { return 1; }
// equivalent to:
function f() { return Promise.resolve(1); }
async function g() { throw new Error("x"); }
// equivalent to:
function g() { return Promise.reject(new Error("x")); }
Once you internalize that, the rest follows. Every “trap” below is a place where the syntactic sugar hides which Promise object is actually being created.
The same function, three ways
// 1. Raw .then chain
function getUserPosts(id) {
return fetch(`/api/users/${id}`)
.then((r) => r.json())
.then((user) => fetch(`/api/posts?author=${user.id}`))
.then((r) => r.json());
}
// 2. async/await, identical behavior
async function getUserPosts(id) {
const userRes = await fetch(`/api/users/${id}`);
const user = await userRes.json();
const postsRes = await fetch(`/api/posts?author=${user.id}`);
return postsRes.json();
}
// 3. Mixed (often the most readable for short branches)
async function getUserPosts(id) {
const user = await fetch(`/api/users/${id}`).then((r) => r.json());
return fetch(`/api/posts?author=${user.id}`).then((r) => r.json());
}
Versions 2 and 3 are identical at the bytecode level on V8 13+ (Node 22 LTS, Chrome 130+). Pick whichever reads better on the screen the next engineer will see. Long chains read better as await; one-liners read better as .then.
Error handling: .catch vs try/catch
// .catch
fetch("/api/x")
.then((r) => r.json())
.catch((err) => log.warn({ err }, "fetch failed"));
// try/catch
try {
const r = await fetch("/api/x");
const data = await r.json();
} catch (err) {
log.warn({ err }, "fetch failed");
}
// try/catch with finally
try {
const data = await loadConfig();
return data;
} catch (err) {
metrics.increment("config.load.fail");
throw err;
} finally {
span.end();
}
Use try/catch when the same handler covers multiple awaits or when you need a finally. Use .catch when the rejection handler is one line or when you want to localize handling to a single call without indenting the rest.
One trap. async functions always return a Promise. If you forget to await or return the call, errors disappear silently from the calling function — they become unhandled rejections, fatal in Node 15+ but only logged in the browser. The MDN guide to using promises has the full rejection semantics; the typescript-eslint no-floating-promises rule catches the static cases automatically.
Where .then-style still wins: concurrency primitives
Promise.all — fan out, wait for all
// Serial: ~600ms if each call is ~200ms
const user = await fetchUser(id);
const posts = await fetchPosts(id);
const friends = await fetchFriends(id);
// Concurrent: ~200ms
const [user, posts, friends] = await Promise.all([
fetchUser(id),
fetchPosts(id),
fetchFriends(id),
]);
The single most common latency win in real Node services. The recurring r/javascript gotcha thread on unhandled rejections walks through how teams routinely turn 500ms request handlers into 200ms by spotting independent serial awaits. Caveat: Promise.all rejects on the first rejection and lets the other in-flight calls run to completion silently. If you need every result with their failure status, use Promise.allSettled below.
Promise.allSettled — fan out, never reject
const results = await Promise.allSettled([
refreshCache(),
refreshIndex(),
refreshFeatureFlags(),
]);
for (const r of results) {
if (r.status === "rejected") log.warn({ err: r.reason }, "refresh failed");
}
The pattern for “run N independent operations, report which succeeded and which failed.” Used in dashboards, batch jobs, and warmup hooks where partial failure is acceptable. Never rejects.
Promise.race — first to settle wins
// Old custom timeout (legacy code)
const timeout = (ms) =>
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms));
const data = await Promise.race([fetch("/slow"), timeout(2000)]);
// Modern (Node 17.3+, all evergreen browsers)
const data = await fetch("/slow", { signal: AbortSignal.timeout(2000) });
Promise.race is still useful when racing two real operations (cache vs origin, primary vs replica). For request timeouts, use AbortSignal.timeout(): it actually cancels the underlying request instead of orphaning it the way a hand-rolled Promise.race does. AbortSignal.any() composes multiple signals (user cancel + timeout) into one.
Promise.any — first success wins
const fastest = await Promise.any([
fetch("https://us-east.example.com/health"),
fetch("https://eu-west.example.com/health"),
fetch("https://ap-south.example.com/health"),
]);
Promise.any resolves on the first fulfilled Promise and rejects with an AggregateError only if all reject. Useful for geo-distributed reads where you want lowest latency without caring which region answered.
Decision table
| Goal | Use | Failure mode |
|---|---|---|
| All succeed or fail together | Promise.all |
First rejection rejects the group; others orphaned |
| Per-result success/failure | Promise.allSettled |
Never rejects; inspect the results array |
| First to settle (resolve or reject) | Promise.race |
First rejection kills the race |
| First success wins; ignore rejections | Promise.any |
Rejects with AggregateError only if all reject |
The two ES2024 additions worth learning
Promise.try — lift a maybe-sync function into a Promise
// The old workaround
new Promise((resolve) => resolve(maybeSyncFn()));
// breaks if maybeSyncFn throws synchronously
// The other old workaround
Promise.resolve().then(maybeSyncFn);
// always defers a tick, even when not needed
// ES2024
Promise.try(maybeSyncFn)
.then((v) => use(v))
.catch((err) => handle(err));
The case it solves: you have a function that may return a value, throw synchronously, or return a Promise, and you want to handle all three uniformly with .then/.catch. Before Promise.try, you had to write the new Promise wrapper yourself or eat the extra microtask of Promise.resolve().then. Available in Node 22+ and all evergreen browsers since early 2025; per MDN, it has Baseline status.
Promise.withResolvers — resolve a Promise from outside
// The old pattern
let resolve, reject;
const p = new Promise((res, rej) => { resolve = res; reject = rej; });
// ES2024
const { promise, resolve, reject } = Promise.withResolvers();
eventEmitter.once("done", (value) => resolve(value));
eventEmitter.once("error", (err) => reject(err));
return promise;
The case it solves: you need a Promise whose resolve/reject are called from elsewhere — event listeners, callbacks, queues. The new Promise + outer let dance was the only way to do this for years; Promise.withResolvers ([MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers)) makes it one line. Available across Node 22+ and modern browsers.
Top-level await in Node 22
// config.mjs (ES module)
const env = await loadConfig();
export default env;
Top-level await is supported in ES modules in Node 14.8+ and all evergreen browsers; Node 22 makes it the default for ESM. It blocks module evaluation until the Promise resolves. That is exactly what you want for one-time boot config loaders. It is exactly what you do not want in a request handler module that gets pulled into a hot path. The cost compounds: every consumer of the importing module pays the latency on first load, which appears in cold-start metrics on serverless.
Node 22 LTS also makes fetch stable and ships --watch mode, both of which interact with this guide’s patterns. The Node 22 release notes are the canonical reference.
Performance: the same primitives
V8 closed the perf gap between async/await and equivalent .then chains around 2018-2019; on Node 22 LTS in 2026 they compile to nearly identical bytecode. Microbenchmarks claiming a difference are usually measuring engine inlining of trivial Promises, not real workloads. The expensive operation is almost always the I/O, not the syntax that suspends on it.
The only real perf trap is structural: a long chain of awaited independent calls is several hundred milliseconds slower than the equivalent Promise.all. The no-await-in-loop ESLint rule flags the most common case; typescript-eslint‘s await-thenable and no-floating-promises handle the rest.
Five patterns people get wrong
- Serial
awaiton independent calls.// wrong for (const id of ids) { results.push(await fetchOne(id)); } // right const results = await Promise.all(ids.map(fetchOne)); asyncin a callback that does not await.// wrong: forEach ignores the returned Promises arr.forEach(async (x) => { await save(x); }); // right: for...of awaits sequentially for (const x of arr) { await save(x); } // right: Promise.all + map awaits concurrently await Promise.all(arr.map(save));- Forgetting to
returnfrom inside.then. The next.thenreceivesundefinedand you get a crypticTypeErrortwo steps later. See Cannot read properties of undefined. - Expecting
catchto fire for the return value’s later errors.// wrong: rejection of the returned Promise is unhandled async function caller() { try { return loadData(); // missing await } catch (err) { /* never fires */ } } // right: await the call so the catch sees its rejection async function caller() { try { return await loadData(); } catch (err) { /* fires */ } } - Hand-rolling timeouts when
AbortSignal.timeoutexists. Outside of legacy environments, the modern primitive is correct, cancels the underlying I/O, and composes viaAbortSignal.any.
FAQ
Is async/await faster than Promises?
No. V8 compiles them to nearly identical bytecode in 2026. If you see a benchmark claiming a difference, check the date — the gap closed around 2018-2019. The expensive part is the I/O.
When should I use Promise.all instead of serial await?
Whenever the calls do not depend on each other’s results. If call 2 needs the output of call 1, keep them serial. If they are independent, Promise.all is a free latency win.
How do I convert a callback API to Promises?
Use util.promisify in Node for stdlib callbacks that follow the (err, result) convention. For one-off custom callbacks, Promise.withResolvers is the cleanest pattern. Do not write new Promise wrappers around APIs that already return Promises — it is a common cause of double-rejection bugs.
Why does await inside a map callback not actually wait?
Because map itself is synchronous; it does not understand the Promises its callback returns. arr.map(async x => ...) produces an array of Promises, which is what Promise.all expects. Wrap the call: await Promise.all(arr.map(asyncFn)).
Should I use Promise.try in new code?
Yes when you have a function that may be sync or async and you want one chain that handles both. Otherwise the answer is “you probably do not need it” — most codebases never hit the case it solves.
Does top-level await work everywhere?
In every evergreen browser since 2022 and Node 14.8+ in ES modules. Use it for boot-time config loaders. Avoid it in modules that sit on a hot request path; the cost compounds on cold starts.
What is the difference between Promise.all and Promise.allSettled?
Promise.all rejects on the first rejection and discards the others. Promise.allSettled never rejects; it returns a uniform array of {status, value} or {status, reason} objects. Use all when “any failure means abort.” Use allSettled when “report which succeeded and which failed.”
Sources and further reading
- MDN — Using promises
- MDN —
Promise.try() - MDN —
Promise.withResolvers() - MDN —
AbortSignal.timeout() - Node 22 LTS release notes — fetch stable, ESM defaults,
--watch - typescript-eslint
no-floating-promisesrule - ECMA-262 spec — Promise Objects
Next steps
If a Promise bug is showing up as a cryptic TypeError, the Cannot read properties of undefined fix is the companion article. For scheduling and microtask ordering questions async/await raises, the event loop guide is the next thing to read. For TypeScript-specific patterns around generic Promises, see TypeScript generics with 7 examples.