~/comparisons/javascript-async-await-vs-promises-differences-that-matter
§ POST · MAY 12, 2026 v1.0

JavaScript async/await vs Promises: differences that matter

Async/await vs Promises in JavaScript: the same machine underneath, but 5 patterns where one wins clearly. Promise.all, error handling, and the event loop.
Ryan CallowayStaff contributor
  9 min read

By Ryan Calloway. Updated May 2026.

Quick Verdict
Best forasync/await for sequential async code; Node 22 LTS and modern browsers
Not best forconcurrency primitives — reach for Promise.all, allSettled, race, any, and the new Promise.try / Promise.withResolvers
Watch out forfloating Promises in Node 15+ (process exits), await inside forEach, and module-blocking top-level await in request paths
Pro tipenable typescript-eslint‘s no-floating-promises rule and use AbortSignal.timeout() instead of hand-rolled Promise.race timeouts

An 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

  1. Serial await on independent calls.
    // wrong
    for (const id of ids) {
      results.push(await fetchOne(id));
    }
    // right
    const results = await Promise.all(ids.map(fetchOne));
  2. async in 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));
  3. Forgetting to return from inside .then. The next .then receives undefined and you get a cryptic TypeError two steps later. See Cannot read properties of undefined.
  4. Expecting catch to 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 */ }
    }
  5. Hand-rolling timeouts when AbortSignal.timeout exists. Outside of legacy environments, the modern primitive is correct, cancels the underlying I/O, and composes via AbortSignal.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

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.

esc