Promises & async/await Interview Questions & Answers

34 questions Updated 2026-06-17

JavaScript promise and async/await interview questions — states, chaining, error handling, Promise.all vs race, and converting callbacks.

Read the in-depth guideJavaScript Promises & Async/Await — The Complete Guide

A Promise is an object representing the eventual result of an asynchronous operation — a placeholder for a value you don't have yet. It lives in one of three states:

  • pending — the starting state, not yet settled.
  • fulfilled — completed successfully, carries a value.
  • rejected — failed, carries a reason (usually an Error).
const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve('done'), 100) // settles after 100ms
})
p.then(value => console.log(value)) // 'done'

Two properties make promises predictable: they're settled once (a pending promise transitions to fulfilled or rejected exactly one time and then its state/value are immutable), and .then/.catch always run asynchronously as microtasks — never synchronously, even for an already-resolved promise.

async/await is syntactic sugar over promises — it doesn't add new capabilities, it makes promise code read like ordinary sequential code. Two rules: an async function always returns a promise (any value you return is wrapped in a resolved promise; any throw becomes a rejected one), and await pauses the function until the awaited promise settles, then resumes with its value.

async function load() {
  const res = await fetch('/api')   // pause until the request resolves
  return res.json()                 // also awaited by the caller
}
load().then(data => console.log(data)) // still a promise underneath

Crucially, await only suspends the current async function — the rest of the program keeps running. Under the hood the code after an await becomes a .then continuation scheduled as a microtask.

With raw promises, attach .catch() to handle a rejection anywhere up the chain. With async/await, an awaited rejected promise throws, so you use a normal try/catch.

// promise style
load().then(use).catch(err => console.error(err))

// async/await style — reads like synchronous try/catch
try {
  const data = await load()
  use(data)
} catch (err) {
  console.error(err)
} finally {
  hideSpinner() // runs whether it succeeded or failed
}

Gotchas interviewers probe: a single .catch() at the end catches errors from every prior step in the chain; an unhandled rejection triggers an unhandledrejection event/warning; and forgetting to await (or return) a promise inside a try means its rejection escapes the catch entirely.

Both take an iterable of promises and run them concurrently, but they differ in how they handle failure:

  • Promise.all short-circuits: it fulfills with an array of all values once every promise fulfills, but rejects immediately the moment any one rejects — you lose the results of the others.
  • Promise.allSettled never rejects: it waits for every promise to settle and resolves with an array of { status, value } or { status, reason } objects, so you can inspect each outcome.
const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()])
results.forEach(r => {
  if (r.status === 'fulfilled') console.log('ok', r.value)
  else console.log('failed', r.reason)
})

Reach for all when you need all-or-nothing (one failure should abort); reach for allSettled when you want every result regardless of partial failures (e.g. a dashboard of independent widgets).

Both settle based on the first promise to finish, but they disagree on what counts:

  • Promise.race settles as soon as the first promise settles — whether it fulfills or rejects. The first one to cross the line wins, error or not.
  • Promise.any resolves with the first promise to fulfill, ignoring rejections. It only rejects if all of them reject, with an AggregateError bundling every reason.
// timeout pattern — race the work against a timer
const data = await Promise.race([
  fetch('/api'),
  new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 5000)),
])

// first successful mirror wins; only fails if every mirror is down
const fastest = await Promise.any([fetch(mirror1), fetch(mirror2)])

Mnemonic: race cares about first to finish, any cares about first to succeed.

The difference is when you start each task. await inside a loop starts the next task only after the previous one resolves — that's sequential and often an accidental performance bug. To go parallel, kick them all off first (so they run concurrently), then await them together.

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

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

Use sequential intentionally when each step depends on the previous one's result or you must avoid hammering a rate-limited API. Use parallel when the tasks are independent — it can be dramatically faster.

Because promise continuations are scheduled as microtasks, while setTimeout callbacks are macrotasks — and the event loop drains the entire microtask queue after the current synchronous code, before it ever reaches the next macrotask.

console.log(1)
setTimeout(() => console.log(2), 0)        // macrotask
Promise.resolve().then(() => console.log(3)) // microtask
console.log(4)
// Output: 1, 4, 3, 2 — the promise (3) beats the timer (2)

So even setTimeout(fn, 0) is effectively "run after all pending microtasks," which is why a resolved promise's .then always fires first.

Wrap the callback API in new Promise(...) and translate the callback's outcome into resolve/reject. For Node-style (err, value) callbacks, the convention is "error first": reject on err, otherwise resolve with the value.

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

// wrapping a Node-style error-first callback
const readFileAsync = path =>
  new Promise((resolve, reject) => {
    fs.readFile(path, 'utf8', (err, data) => {
      if (err) reject(err)
      else resolve(data)
    })
  })

In Node you'd usually reach for the built-in util.promisify instead of hand-rolling this, but interviewers like to see you can build it from scratch.

Each .then returns a new promise that resolves with whatever its callback returns, so you can chain steps where each receives the previous result. This flattens nested callbacks into a linear sequence.

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

The chain runs step by step; returning a value passes it down, returning a promise waits for it. A single trailing .catch handles errors from any step.

.then always returns a new promise. What that promise resolves to depends on the callback: a plain value -> resolves with it; a promise -> adopts that promise's eventual value (flattening); a throw -> rejects.

Promise.resolve(1)
  .then(x => x + 1)               // resolves 2
  .then(x => Promise.resolve(x*10)) // resolves 20 (adopted)
  .then(x => { throw new Error() }) // rejects
  .then(x => console.log('skipped'))
  .catch(() => console.log('caught'))

Because each .then is a fresh promise, chaining and error propagation "just work" — and forgetting to return inside a .then breaks the chain (the next step gets undefined).

Usually a single .catch at the end of the chain — it handles a rejection from any preceding step, because errors propagate down until caught. Place an intermediate .catch only if you want to recover and continue the chain.

step1().then(step2).then(step3)
  .catch(err => console.error('any step failed', err))

// recover mid-chain:
load().catch(() => fallback()).then(use) // continues with fallback's value

A .catch that returns a value produces a resolved promise, so the chain continues normally after recovery.

.finally(cb) runs cb whether the promise fulfilled or rejected — for cleanup that should always happen (hide a spinner, close a connection). It receives no argument and, importantly, passes the original value/error through unchanged.

showSpinner()
fetchData()
  .then(use)
  .catch(handle)
  .finally(() => hideSpinner()) // always runs, doesn't alter the result

Caveat: if .finally's callback itself throws or returns a rejected promise, it does override the outcome — but a normal return is ignored.

A rejected promise with no .catch (or no try/catch around its await) is "unhandled." Browsers fire an unhandledrejection event and log a warning; Node prints a warning and (in recent versions) crashes the process by default.

Promise.reject(new Error('boom')) // no handler -> unhandled rejection

window.addEventListener('unhandledrejection', e => {
  console.error('unhandled:', e.reason)
  e.preventDefault()
})

Always attach a .catch or wrap awaits in try/catch. A global handler is a safety net for logging, not a substitute for handling errors at the source.

They create already-settled promises: Promise.resolve(v) returns a promise fulfilled with v (or adopts v if it's already a promise/thenable), and Promise.reject(e) returns one rejected with e. Handy for returning a consistent promise type or starting a chain.

function getUser(id) {
  const cached = cache.get(id)
  if (cached) return Promise.resolve(cached) // sync value as a promise
  return fetch(`/api/${id}`).then(r => r.json())
}

Promise.resolve is also how await wraps non-promise values, and how you normalize "maybe a value, maybe a promise" into always-a-promise.

A thenable is any object with a .then(resolve, reject) method. The promise machinery treats thenables like promises — await and Promise.resolve will "follow" a thenable by calling its then. This is what makes different promise libraries interoperate.

const thenable = {
  then(resolve) { resolve(42) }
}
Promise.resolve(thenable).then(v => console.log(v)) // 42
await thenable                                       // 42

It's why returning a thenable from .then flattens it. The downside: any object that happens to have a then method gets treated specially — a rare source of surprises.

Throwing inside a .then (or .catch) callback rejects the promise that .then returns, so the error skips subsequent .thens and is caught by the next .catch down the chain.

Promise.resolve()
  .then(() => { throw new Error('fail') })
  .then(() => console.log('skipped'))      // not run
  .catch(err => console.log('caught:', err.message)) // caught: fail

This is what makes promise error handling feel like synchronous try/catch — a thrown error propagates to the nearest handler instead of crashing.

The outer chain waits for that returned promise to settle and adopts its result — this is "flattening." It's how you sequence async steps without nesting.

getUser()
  .then(user => fetchPosts(user.id)) // returns a promise
  .then(posts => console.log(posts)) // runs only after fetchPosts resolves

Promises never nest as Promise<Promise<T>>; returning a promise from .then auto-unwraps it. Forgetting to return it, though, means the next step runs immediately with undefined.

Nesting .thens recreates callback hell. Because returning a promise flattens the chain, you can keep it flat by returning instead of nesting.

// nested ("promise hell")
a().then(x => {
  b(x).then(y => {
    c(y).then(z => console.log(z))
  })
})
// flat
a().then(b).then(c).then(z => console.log(z))

async/await flattens it even more readably. Nesting is occasionally needed when an inner step requires an outer value, but usually a flat chain (or await) avoids it.

Chain them by reducing over an array, where each step awaits the accumulator promise before starting — guaranteeing order and one-at-a-time execution.

const urls = ['/a', '/b', '/c']
await urls.reduce(
  (chain, url) => chain.then(() => fetch(url)),
  Promise.resolve()
)
// or with await in a for...of:
for (const url of urls) await fetch(url)

Use this when each request must finish before the next (ordering, rate limits, dependencies). For independent requests, Promise.all is faster.

Running thousands of requests at once with Promise.all can overwhelm a server or hit rate limits. Cap the number in flight with a worker pool: N workers pulling from a shared queue.

async function pool(items, limit, fn) {
  const results = []
  const queue = [...items.entries()]
  async function worker() {
    for (const [i, item] of queue.splice(0)) results[i] = await fn(item)
  }
  await Promise.all(Array.from({ length: limit }, worker))
  return results
}
await pool(urls, 5, fetch) // at most 5 concurrent requests

Libraries like p-limit provide this off the shelf. The idea: parallelism, but bounded.

Wrap the operation in a loop that catches failures and waits an increasing delay before retrying, up to a max attempt count.

async function retry(fn, attempts = 3, delay = 200) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn()
    } catch (err) {
      if (i === attempts - 1) throw err
      await new Promise(r => setTimeout(r, delay * 2 ** i)) // 200, 400, 800...
    }
  }
}

Exponential backoff (often with jitter) avoids hammering a struggling service. Only retry idempotent/transient failures — retrying a non-idempotent write can duplicate effects.

for await...of iterates an async iterable (or an iterable of promises), awaiting each value in turn — ideal for consuming streams or paginated APIs sequentially.

for await (const chunk of readableStream) {
  process(chunk) // each chunk awaited in order
}

for await (const value of [fetch('/a'), fetch('/b')]) {
  console.log(value) // awaits each promise sequentially
}

It processes items one at a time (sequential). For concurrent processing of a fixed array, Promise.all(arr.map(...)) is the right tool instead.

In ES modules, you can use await at the top level, outside any async function. The module's evaluation pauses until the awaited promise settles, and importers wait for it.

// config.mjs
const res = await fetch('/config.json')
export const config = await res.json()

It's great for module initialization (loading config, dynamic imports, connecting). Caveat: it can delay the loading of dependent modules, so avoid slow top-level awaits in widely-imported modules. Not available in CommonScript scripts.

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

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

For your own async functions, check signal.aborted at await points and bail out. The promise still settles (as a rejection); "cancellation" means stopping the underlying work and ignoring the result.

Array.prototype.forEach ignores the return value of its callback, so it doesn't await the async callback's promise — the loop fires all callbacks and moves on immediately, not waiting for any of them.

// does NOT wait; "done" logs before any item is processed
items.forEach(async item => { await process(item) })
console.log('done')

// sequential
for (const item of items) await process(item)
// concurrent
await Promise.all(items.map(item => process(item)))

Use for...of (sequential) or map + Promise.all (concurrent). forEach is simply not async-aware.

Call the async functions first (which starts them immediately) and store the promises, then await them afterward. The work overlaps because it began before any await.

const pa = fetchA() // starts now
const pb = fetchB() // starts now (concurrently)
const a = await pa  // wait for both, but they already ran in parallel
const b = await pb

Contrast with const a = await fetchA(); const b = await fetchB() which is sequential. Promise.all([pa, pb]) is the cleaner equivalent and also propagates errors as soon as one fails.

Map each element to a promise (starting all the work), then Promise.all to wait for the whole batch and collect results in order.

const results = await Promise.all(
  ids.map(id => fetchUser(id))
)
// results[i] corresponds to ids[i], even if they resolved out of order

This runs everything concurrently — far faster than awaiting in a loop. Beware unbounded concurrency for large arrays (use a concurrency limit), and remember Promise.all rejects as soon as any task fails.

Wrapping an already-promise-returning operation in new Promise is redundant, verbose, and easy to get wrong (forgetting to reject, swallowing errors). Just return/await the existing promise.

// antipattern — wrapping a promise in a promise
function load() {
  return new Promise((resolve, reject) => {
    fetch('/x').then(resolve).catch(reject)
  })
}
// just return it
function load() { return fetch('/x') }

Use new Promise only to wrap a callback-based API that isn't already promisified.

They're interchangeable, but async/await usually reads better, especially with branching, loops, and try/catch error handling. .then chains shine for simple linear transformations and point-free style.

// await: clearer with logic between steps
async function load() {
  const user = await getUser()
  if (!user.active) return null
  return getPosts(user.id)
}
// then: fine for a straight pipeline
const upper = () => fetch('/x').then(r => r.text()).then(t => t.toUpperCase())

Mixing is fine. Prefer await for readability; just remember every await introduces a microtask boundary.

No. A promise transitions once from pending to either fulfilled or rejected, and after that its state and value are immutable. Calling resolve/reject again does nothing.

const p = new Promise((resolve, reject) => {
  resolve('first')
  resolve('second') // ignored
  reject('error')   // ignored
})
p.then(v => console.log(v)) // 'first'

This guarantee is what makes promises composable: once you have a settled promise, its result never changes, and adding more .thens later still gives you that same value.

Promise.all([]) resolves immediately with an empty array — there's nothing to wait for. The same edge case applies to allSettled([]) (empty array). But Promise.any([]) rejects with an AggregateError (nothing can fulfill), and Promise.race([]) stays pending forever.

await Promise.all([])      // []
await Promise.allSettled([]) // []
await Promise.race([])     // never settles

These empty-array behaviors are classic gotchas — guard against an empty input list if it could occur, especially for race/any.

Use Promise.allSettled, which waits for every task and reports each outcome, so one failure doesn't discard the successes (as Promise.all would).

const results = await Promise.allSettled(urls.map(fetch))
const ok = results.filter(r => r.status === 'fulfilled').map(r => r.value)
const failed = results.filter(r => r.status === 'rejected').map(r => r.reason)

This is the right tool for dashboards, batch jobs, and "best-effort" operations where you want all results regardless of individual failures.

Calling an async function without awaiting or attaching .catch ("floating promise") means errors become unhandled rejections, and you lose control over ordering and completion.

// floating — errors vanish, no way to know when it's done
saveAnalytics(data)

// at least handle errors
saveAnalytics(data).catch(err => log(err))
// or await if the result/ordering matters
await saveAnalytics(data)

If you intentionally don't await, always attach a .catch. Linters (no-floating-promises) flag these because silent failures are hard to debug.

A single try/catch around the awaits catches a rejection from any of them — execution jumps to catch at the first failure, skipping the rest.

try {
  const user = await getUser()
  const posts = await getPosts(user.id)
  const stats = await getStats(posts)
} catch (err) {
  // catches whichever await rejected first
  console.error(err)
}

For independent operations where you want all errors (not just the first), use Promise.allSettled and inspect each result instead of letting the first rejection short-circuit.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.