Deep Dive12 min read · Updated 2025-06-01

JavaScript Promises & Async/Await: The Complete Mental Model

Most developers use Promises and async/await daily but can't explain why a setTimeout with 0ms delay still runs after a resolved Promise. This is the guide that fixes that gap — from the execution model up.

💡 Practice these concepts interactively with AI feedback

Start Practicing →

Here's the JavaScript interview question that trips up senior developers: given a resolved Promise and a setTimeout with a 0ms delay, which runs first? Most people guess wrong because they don't have a model — they just have experience using these tools.

This guide builds the model. By the end, you won't guess. You'll know.

The Problem Promises Were Built to Solve

Before Promises, JavaScript handled async operations with callbacks. The problem wasn't the nesting depth — that's cosmetic. The real problem was inversion of control.

When you pass a callback to a third-party function, you're trusting that function to call it: once, with the right arguments, in a reasonable time. You've handed over control. The third party might call it twice, never, or synchronously when you expected async behavior.

Promises restored control to the caller. A Promise is a standardized contract: it will settle exactly once, into exactly one of two outcomes (fulfilled or rejected), and every handler you attach will be called with that outcome — even if you attach it after the Promise already settled.

The Three States

A Promise is always in one of three states. The transitions are one-way and permanent.

// pending → fulfilled
new Promise(resolve => setTimeout(() => resolve('done'), 1000))

// pending → rejected new Promise((_, reject) => reject(new Error('failed')))

// Already settled — .then() still fires, asynchronously const p = Promise.resolve(42) p.then(v => console.log(v)) // logs 42, after current call stack clears

Once a Promise is settled, its state and value are frozen. Attach .then() a minute later — it still fires with the same value.

What the Executor Actually Does

The function you pass to new Promise() runs synchronously, right now, before new Promise() even returns:

console.log('before')
const p = new Promise(resolve => {
  console.log('inside executor') // runs NOW
  resolve(1)
})
console.log('after')
// Output: before, inside executor, after

Only the handlers (.then() callbacks) are asynchronous — they're scheduled as microtasks. The executor itself is synchronous.

Promise Chains — The Part Everyone Gets Wrong

Every .then() returns a new Promise. The original Promise is never modified. The new Promise resolves to whatever the handler returns.

const p = Promise.resolve(1)
  .then(n => n + 1)     // returns 2 (plain value) → next Promise resolves to 2
  .then(n => n * 10)    // returns 20
  .then(n => {
    if (n > 10) throw new Error('too big') // throw → next Promise rejects
    return n
  })
  .catch(err => {
    console.log(err.message) // 'too big'
    return 0                 // returning from catch → fulfilled with 0
  })
  .then(n => console.log(n)) // 0

The rule: the return value of a .then() handler determines what the next .then() receives. Return a plain value — next gets that value. Return a Promise — chain waits for it to settle. Throw — chain jumps to next .catch().

The most common mistake: forgetting to return inside .then().

// ❌ Broken — next .then() receives undefined
fetch('/api/user')
  .then(r => {
    r.json()           // no return!
  })
  .then(user => console.log(user)) // undefined

// ✓ Fixed fetch('/api/user') .then(r => r.json()) .then(user => console.log(user))

async/await — What It Actually Is

async/await is syntactic sugar over Promises. These two functions are behaviorally identical:

// With Promises
function loadUser(id) {
  return fetch(/api/users/${id})
    .then(r => r.json())
    .then(user => {
      if (!user) throw new Error('Not found')
      return user
    })
}

// With async/await async function loadUser(id) { const r = await fetch(/api/users/${id}) const user = await r.json() if (!user) throw new Error('Not found') return user }

Same execution model, same Promise chain under the hood. async always returns a Promise. await pauses the function and schedules its continuation as a microtask. The thread never actually blocks.

The Execution Order — Why Promise Beats setTimeout

This is the question. Here's the full trace:

console.log('1')                          // sync
setTimeout(() => console.log('2'), 0)     // macrotask registered
Promise.resolve().then(() => {
  console.log('3')                        // microtask registered
  setTimeout(() => console.log('4'), 0)   // another macrotask
})
console.log('5')                          // sync

// Output: 1, 5, 3, 2, 4

Why: JavaScript's event loop rule is strict — after each task, drain the entire microtask queue before running any macrotask. The sequence:

setTimeout is a macrotask. Promise .then() is a microtask. Microtasks always run first.

The Four Static Methods You Must Know

Promise.all — parallel execution, fail-fast. All must resolve; first rejection rejects everything.

const [users, posts] = await Promise.all([fetchUsers(), fetchPosts()])
// Both start simultaneously. Total time = slowest one, not their sum.

Promise.allSettled — parallel, never throws. Returns outcome objects for everything.

const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()])
results.forEach(r => {
  if (r.status === 'fulfilled') use(r.value)
  else                          logError(r.reason)
})
// Use for dashboards, batch operations where partial success is acceptable

Promise.race — first to settle wins, fulfilled or rejected.

// Timeout wrapper — the clean way
function withTimeout(promise, ms) {
  const timer = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(Timed out after ${ms}ms)), ms)
  )
  return Promise.race([promise, timer])
}

Promise.any — first fulfillment wins. Ignores rejections. Only rejects if all reject.

// Fastest CDN wins
const asset = await Promise.any([
  fetch('https://cdn1.example.com/asset.js'),
  fetch('https://cdn2.example.com/asset.js'),
])

Sequential vs Parallel — The Performance Trap

// ❌ Sequential — 900ms total (300 + 300 + 300)
async function loadDashboard() {
  const users    = await fetchUsers()    // waits
  const products = await fetchProducts() // waits AFTER users
  const orders   = await fetchOrders()   // waits AFTER products
  return { users, products, orders }
}

// ✓ Parallel — 300ms total (all start at once) async function loadDashboard() { const [users, products, orders] = await Promise.all([ fetchUsers(), fetchProducts(), fetchOrders() ]) return { users, products, orders } }

This is the highest-impact async optimization in frontend code. A 3x difference with one change.

Error Handling That Actually Works

// One try/catch for the whole chain
async function loadProfile(userId) {
  try {
    const user    = await fetchUser(userId)
    const profile = await fetchProfile(user.id)
    return profile
  } catch (err) {
    // Catches any rejection from either await
    logger.error('Profile load failed', { userId, err })
    throw err  // re-throw so the caller knows — don't swallow errors
  } finally {
    setLoading(false) // always runs
  }
}

// Per-operation handling when failures are independent async function loadDashboard(userId) { const user = await fetchUser(userId) // if this fails, propagate

const [postsResult, statsResult] = await Promise.allSettled([ fetchPosts(userId), fetchStats(userId), ])

return { user, posts: postsResult.status === 'fulfilled' ? postsResult.value : [], stats: statsResult.status === 'fulfilled' ? statsResult.value : null, } }

The fetch() Gotcha That Catches Everyone

fetch() only rejects on network failure. A 404 or 500 is a fulfilled Promise with a response whose ok property is false. You must check it manually:

async function apiFetch(url) {
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(HTTP ${response.status}: ${url})
  }
  return response.json()
}

Every production codebase that uses fetch needs this pattern. The alternative is silently processing error responses as if they were successful data.

async forEach — The Broken Pattern

// ❌ forEach ignores returned Promises — this doesn't await anything
const ids = [1, 2, 3]
ids.forEach(async (id) => {
  const user = await fetchUser(id) // fires and is forgotten
  console.log(user.name)
})
console.log('done') // logs BEFORE any user is fetched

// ✓ for...of — sequential, in order for (const id of ids) { const user = await fetchUser(id) console.log(user.name) }

// ✓ Promise.all + map — parallel const users = await Promise.all(ids.map(id => fetchUser(id)))

Pick for...of when order matters or operations depend on each other. Pick Promise.all + map when operations are independent and you want maximum speed.

Quick Reference

Promise.any → first to succeed*
📚 Practice These Topics
SetTimeout
4–7 questions
Promises
7–12 questions
Async await
5–8 questions
Callbacks
3–5 questions

Put This Into Practice

Reading articles is passive. JSPrep Pro makes you actively recall, predict output, and get AI feedback.

Start Free →Browse All Questions

Related Articles

Core Concepts
map() vs forEach() in JavaScript: Which One to Use and Why It Matters
7 min read
Core Concepts
Arrow Functions vs Regular Functions in JavaScript: 6 Key Differences
9 min read
Interview Prep
Promise.all vs allSettled vs race vs any: The Complete Comparison
9 min read