JavaScript · Asynchronous JavaScript

JavaScript Promises & Async/Await — The Complete Guide

6 min read Updated 2026-06-17

Practice Promises & async/await interview questions

A complete guide to promises and async/await

Asynchronous code is unavoidable in JavaScript — anything involving the network, the file system, or timers happens later, not now. Promises are the language's standard abstraction for "a value you'll have eventually," and async/await is the syntax that makes them read like ordinary code. This guide covers what promises are, how chaining and error handling work, the combinator methods, and the patterns and traps that separate confident developers from confused ones.

What a promise is

A promise is an object representing the eventual result of an async operation. It lives in one of three states:

  • pending — not yet settled.
  • fulfilled — completed successfully, holds a value.
  • rejected — failed, holds a reason.
const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve('done'), 100)
})
p.then(value => console.log(value)) // 'done'

Two guarantees make promises composable. A promise settles once — after that its state and value are immutable, so calling resolve/reject again does nothing. And .then/.catch callbacks always run asynchronously as microtasks, never synchronously — even for an already-resolved promise. One subtlety: the executor function you pass to new Promise runs synchronously, immediately; only the callbacks are deferred.

Chaining

Each .then returns a new promise that resolves to whatever its callback returns. That's what lets you chain steps into a linear sequence instead of nesting callbacks.

fetch('/api/user')
  .then(res => res.json())              // returns parsed body
  .then(user => fetch(`/posts/${user.id}`))
  .then(res => res.json())
  .then(posts => console.log(posts))
  .catch(err => console.error(err))     // one handler for any step

Returning a value passes it down; returning a promise makes the chain wait for it (flattening — promises never nest as Promise<Promise<T>>); throwing rejects the chain. The classic bug is forgetting to return inside a .then, which hands the next step undefined. Keep chains flat by returning rather than nesting.

async/await

async/await is syntactic sugar over promises. An async function always returns a promise; await pauses the function until a promise settles, then resumes with its value.

async function load() {
  const res = await fetch('/api')   // pause until resolved
  return res.json()                 // the caller's promise adopts this
}

await only suspends the current function — the rest of the program keeps running. Under the hood, code after an await becomes a .then continuation scheduled as a microtask, so it always runs after the current synchronous task. Even await 5 (a non-promise) defers one microtask tick.

Error handling

With raw promises, attach .catch(); a single trailing .catch handles a rejection from any prior step. With async/await, an awaited rejected promise throws, so use try/catch.

try {
  const data = await load()
  use(data)
} catch (err) {
  console.error(err)
} finally {
  hideSpinner() // runs on success or failure
}

Throwing inside a .then rejects the promise it returns, so errors skip subsequent .thens until a .catch — exactly like synchronous try/catch. A rejected promise with no handler becomes an unhandled rejection: browsers fire an unhandledrejection event; Node warns and (recently) crashes. Always handle rejections at the source — a global handler is a safety net, not a substitute.

Running tasks: sequential vs parallel

The difference is when you start each task. Awaiting in a loop is sequential — often an accidental performance bug. To run in parallel, start them all, then await together.

// sequential: ~A + B + C
for (const url of urls) results.push(await fetch(url))

// parallel: ~max(A, B, C)
const results = await Promise.all(urls.map(url => fetch(url)))

Use sequential when each step depends on the previous one or you must respect a rate limit; use parallel for independent work. For thousands of tasks, cap the in-flight count with a concurrency limit (a worker pool, or a library like p-limit) so you don't overwhelm the server.

A common gotcha: forEach is not async-aware — it ignores the callback's returned promise, so await inside it does nothing useful. Use for...of (sequential) or map + Promise.all (parallel).

The combinators

await Promise.all([a, b, c])        // all values, but rejects as soon as ANY rejects
await Promise.allSettled([a, b, c]) // waits for all; never rejects; {status, value|reason}[]
await Promise.race([a, b])          // first to SETTLE (fulfill or reject) wins
await Promise.any([a, b])           // first to FULFILL; rejects only if all reject
  • all for all-or-nothing (one failure should abort).
  • allSettled for partial failures — dashboards, batch jobs where you want every result.
  • race for timeouts ("work vs a timer").
  • any for "first success wins" (fastest mirror).

Watch the empty-array edge cases: all([])/allSettled([]) resolve immediately, but any([]) rejects with AggregateError and race([]) stays pending forever.

Converting callbacks and cancellation

Wrap a callback API in new Promise, but only when it isn't already promise-based — wrapping an existing promise in new Promise is a well-known anti-pattern.

const wait = ms => new Promise(resolve => setTimeout(resolve, ms))

Promises themselves can't be cancelled — once started they run to settlement. The standard approach is an AbortController: pass its signal to fetch/timers and call abort() to stop the underlying work.

const ctrl = new AbortController()
fetch('/slow', { signal: ctrl.signal }).catch(e => {
  if (e.name === 'AbortError') console.log('cancelled')
})
ctrl.abort()

Why microtasks matter here

console.log(1)
setTimeout(() => console.log(2), 0)         // macrotask
Promise.resolve().then(() => console.log(3)) // microtask
console.log(4)
// 1, 4, 3, 2

Promise continuations are microtasks, which drain after the current synchronous code and before the next macrotask (setTimeout). That's why a resolved promise's .then always runs before a timer — a favorite interview puzzle.

Recap

A promise is a one-time, immutable representation of a future value. Chaining sequences async steps (return values to pass them down, return promises to wait); async/await makes that read synchronously. Handle errors with .catch or try/catch, and never leave a rejection unhandled. Choose sequential vs parallel deliberately, pick the right combinator for your failure semantics, use AbortController to cancel, and remember promise callbacks are microtasks that beat timers. With those pieces, asynchronous JavaScript stops being a source of surprises.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.