Promises & async/await

States, chaining, error handling, and the concurrency helpers — plus how await maps back to the event loop.

must medium ⏱ 22 min promisesasync-awaitconcurrencyerror-handling
Mastery:
Why interviewers ask this
Async correctness separates juniors from mid/senior. Expect questions on error propagation, running things in parallel vs series, and what await actually does.

A Promise is an object representing a value that will exist later. It’s in one of three states: pending, then exactly once it becomes fulfilled (with a value) or rejected (with a reason). Once settled, it never changes again.

Two terms worth saying precisely: a promise is settled once it is either fulfilled or rejected (no longer pending). “Resolved” is slightly different — a resolved promise has been locked to follow another promise/thenable, which may still be pending. In practice “resolved” ≈ “will fulfil/reject based on something else.” The constructor gives you resolve and reject:

Run it yourself
Edit and run. Output is captured from console.log. Async logs (setTimeout/Promise) appear in real execution order.
Console
 

Chaining and flattening

.then returns a new promise, so calls chain. Crucially, if a .then callback returns a promise, the chain waits for it — promises auto-flatten, which is what makes sequential async readable.

fetchUser(id)
  .then(user => fetchPosts(user.id))   // returns a promise → chain waits
  .then(posts => render(posts))
  .catch(err => showError(err))        // one catch handles any earlier rejection
  .finally(() => stopSpinner());       // runs regardless

A single .catch at the end handles a rejection from any preceding step, because a rejection skips .then handlers until it finds a .catch. .catch(fn) is just sugar for .then(undefined, fn).

Flattening rule: what a .then callback returns decides the next promise’s value. Return a plain value → next .then gets that value. Return a promise → the chain waits for it and unwraps it (no nested Promise<Promise<T>>). Throw → the chain rejects.

Run it yourself
Edit and run. Output is captured from console.log. Async logs (setTimeout/Promise) appear in real execution order.
Console
 

async/await is the same thing, nicer

async functions always return a promise. await pauses the function until the awaited promise settles, then resumes with its value (or throws its rejection). With await, you use ordinary try/catch:

async function load(id) {
  try {
    const user = await fetchUser(id);
    const posts = await fetchPosts(user.id);
    render(posts);
  } catch (err) {
    showError(err);
  } finally {
    stopSpinner();
  }
}

await is not blocking the thread
await suspends only the async function, not the whole program. The engine is free to run other tasks meanwhile; when the promise settles, the rest of the function is scheduled as a microtask. (See the Event Loop lesson — everything after an await is a microtask continuation.)

Series vs parallel — the most common mistake

Awaiting in a loop runs requests one at a time. If they’re independent, that’s needlessly slow. Kick them all off, then await together:

Run it yourself
Edit and run. Output is captured from console.log. Async logs (setTimeout/Promise) appear in real execution order.
Console
 

The concurrency combinators

HelperResolves whenRejects whenUse for
Promise.allall fulfilany rejects (fail-fast)need every result
Promise.allSettledall settleneverwant each outcome regardless
Promise.racefirst settlesfirst settles (if reject)timeouts
Promise.anyfirst fulfilsall reject (AggregateError)first success wins

Promise.all — fail-fast; one rejection rejects the whole thing (other promises still run, but their results are discarded). Use when you need every result and any failure is fatal.

Run it yourself
Edit and run. Output is captured from console.log. Async logs (setTimeout/Promise) appear in real execution order.
Console
 

Note the result array preserves input order, not settle order.

Promise.allSettled — never rejects; you get {status, value} or {status, reason} for each. Use when you want partial success (e.g. fetch 10 widgets, render the 8 that worked).

Run it yourself
Edit and run. Output is captured from console.log. Async logs (setTimeout/Promise) appear in real execution order.
Console
 

Promise.race — settles with the first to settle, success or failure. Classic use: a timeout wrapper.

Run it yourself
Edit and run. Output is captured from console.log. Async logs (setTimeout/Promise) appear in real execution order.
Console
 

Promise.any — resolves with the first fulfilment, ignoring rejections; rejects only if all reject (with an AggregateError). Use for “first success wins” (e.g. fastest of several mirrors).

Run it yourself
Edit and run. Output is captured from console.log. Async logs (setTimeout/Promise) appear in real execution order.
Console
 

Converting callbacks to promises

Legacy APIs use (err, result) callbacks. Wrap them once in a promise (this is what Node’s util.promisify does):

Run it yourself
Edit and run. Output is captured from console.log. Async logs (setTimeout/Promise) appear in real execution order.
Console
 

What’s the output? (timing puzzle)

Run it yourself
Edit and run. Output is captured from console.log. Async logs (setTimeout/Promise) appear in real execution order.
Console
 

Answer: 1, 4, 6, 3, 5, 2. Sync: 1, then the async IIFE runs up to await printing 4, then 6. Microtasks: 3 (the .then) and 5 (the await continuation) in scheduling order. Macrotask 2 last.

Error-handling pitfalls

  • Forgotten .catch — an unhandled rejection triggers unhandledrejection (browser) / crashes the process (Node, by default in recent versions).
  • try/catch won’t catch un-awaited promisestry { doAsync(); } catch {} catches nothing; you must await inside the try.
  • Rejection inside a non-async callback — e.g. throwing inside a setTimeout callback isn’t caught by an outer try/catch.
  • Promise.all swallows later results on first reject — use allSettled if you need every outcome.
  • return vs no return in a .then — forgetting to return a promise breaks the chain’s waiting and ordering.
  • async function throwing rejects its returned promise; the caller must .catch/await-in-try.

Common gotchas

  • Sequential awaits for independent work is the #1 perf bug — parallelize with Promise.all.
  • await in a forEach does nothing useful — forEach ignores the returned promise; use a for...of loop (sequential) or map + Promise.all (parallel).
  • Mixing .then and await unnecessarily — pick one style per function for readability.
  • Creating promises that never settle (forgetting to call resolve/reject) leaks pending state.

Interview questions & model answers

Q: What are the promise states? Pending, fulfilled, rejected. A promise settles once (fulfilled or rejected) and is immutable thereafter.

Q: Series vs parallel — how do you parallelize? Start all promises first, then await Promise.all([...]). Awaiting one-by-one in a loop serializes independent work.

Q: all vs allSettled vs race vs any? all: all-or-nothing, fail-fast. allSettled: every outcome, never rejects. race: first to settle (success or failure). any: first fulfilment, rejects only if all fail.

Q: Is await blocking? It suspends only its async function and yields to the event loop; the continuation runs as a microtask. The thread isn’t blocked.

Q: How do error handling styles differ? .then/.catch chains rejections to a trailing .catch; async/await uses ordinary try/catch. Both end up handling the same rejections.

Q: How do you convert a callback API to a promise? Wrap it in new Promise((resolve, reject) => ...), calling resolve/reject from the callback — or use util.promisify in Node.

Q: What happens to an unhandled rejection? It fires unhandledrejection in browsers and, in modern Node, terminates the process by default. Always attach a .catch or wrap awaits in try/catch.

Say it out loud (30s)

“A promise is a future value in pending/fulfilled/rejected. .then returns a new promise and auto-flattens returned promises, so chains stay flat; one trailing .catch handles any rejection. async/await is sugar over this with normal try/catch. await suspends only its function — the continuation runs as a microtask — so to parallelize independent work I start all the promises and Promise.all them rather than awaiting in a loop.”

Likely follow-up questions
  • How do you run independent async calls in parallel instead of in series?
  • Difference between Promise.all, allSettled, race, and any?
  • How does error handling differ between .then/.catch and try/catch with await?
  • Is await blocking?
  • How do you convert a callback-based API into a promise?
  • What happens to an unhandled promise rejection?

References