Intermediate8 questionsFull Guide

JavaScript async/await Interview Questions

async/await is the modern way to write async JavaScript. Master the syntax, error handling patterns, and common pitfalls.

The Mental Model

Picture a surgeon performing an operation. Mid-surgery, they need a lab result. They have two choices: stand at the table with gloves on, doing nothing, waiting for the report to arrive — blocking the entire OR. Or they can pause that specific operation, step out, let the OR staff do other work, and resume the moment the report is in hand. async/await is the second surgeon. When your code hits an await, that specific async function pauses — it steps away from the operating table. But the JavaScript thread keeps running other things. When the awaited Promise settles, the function picks up exactly where it left off, as if it never stopped. The key insight: async/await doesn't make JavaScript multi-threaded or truly blocking. It's syntactic sugar over Promises and generators that makes asynchronous code read like synchronous code. Under the hood, await is just .then() — the function suspends, registers the rest of its body as a microtask callback, and resumes when the Promise resolves. The code looks like it waits. The thread never actually does.

The Explanation

The basic transformation

async/await is syntactic sugar over Promises. Every async function returns a Promise. Every await unwraps a Promise. What the compiler does under the hood is exactly what you'd write manually:

// With async/await — reads like synchronous code
async function loadUserPosts(userId) {
  const user  = await fetch(`/api/users/${userId}`).then(r => r.json())
  const posts = await fetch(`/api/posts?user=${user.id}`).then(r => r.json())
  return posts.filter(p => p.published)
}

// The Promise equivalent — what async/await compiles to in spirit
function loadUserPosts(userId) {
  return fetch(`/api/users/${userId}`)
    .then(r => r.json())
    .then(user => fetch(`/api/posts?user=${user.id}`).then(r => r.json())
      .then(posts => posts.filter(p => p.published))
    )
}

Both do identical things. The async/await version is vastly easier to read, debug, and extend — especially once you have multiple dependent async steps.

async functions — what they actually return

// Any function marked async ALWAYS returns a Promise
async function greet() {
  return 'Hello'   // looks like it returns a string
}

greet()             // Promise { 'Hello' } — wrapped in a Promise automatically
greet().then(console.log)  // 'Hello'

// Thrown errors become rejections
async function fail() {
  throw new Error('broken')
}
fail().catch(err => console.log(err.message))  // 'broken'

// Returning a Promise — it's not double-wrapped, it's adopted
async function fetchUser(id) {
  return fetch(`/api/users/${id}`).then(r => r.json())
  // Returns a Promise — async doesn't wrap it again, it adopts it
}

await — what it does and doesn't do

async function example() {
  console.log('A')                      // runs synchronously
  const result = await Promise.resolve('data')  // pauses HERE
  console.log('B', result)             // resumes as a microtask
  return result
}

console.log('1')
example()
console.log('2')

// Output: 1, A, 2, B data
// Why: '1' → example() starts → 'A' → await pauses example() →
// control returns to caller → '2' → microtask: resumes → 'B data'

Awaiting a non-Promise is legal — it's equivalent to await Promise.resolve(value):

async function test() {
  const x = await 42     // same as await Promise.resolve(42)
  const y = await null   // same as await Promise.resolve(null)
  console.log(x, y)      // 42 null
}

Error handling — try/catch replaces .catch()

// ✓ Standard pattern — try/catch wraps all awaited operations
async function loadDashboard(userId) {
  try {
    const user    = await fetchUser(userId)
    const posts   = await fetchPosts(user.id)
    const metrics = await fetchMetrics(user.id)
    return { user, posts, metrics }
  } catch (err) {
    // Catches rejection from ANY of the three awaits above
    console.error('Dashboard load failed:', err)
    throw err  // re-throw so the caller knows it failed
  }
}

// ✓ Per-operation error handling when failures are independent
async function loadDashboard(userId) {
  const user = await fetchUser(userId)  // let this propagate — critical

  const [posts, metrics] = await Promise.allSettled([
    fetchPosts(user.id),
    fetchMetrics(user.id),
  ])

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

// ✓ Helper pattern for cleaner inline error handling (Go-style)
async function to(promise) {
  try {
    return [null, await promise]
  } catch (err) {
    return [err, null]
  }
}

const [err, user] = await to(fetchUser(id))
if (err) return handleError(err)
// use user safely

Sequential vs parallel — the critical performance distinction

// ❌ Sequential — each await waits for the previous to finish
// Total time: 300ms + 200ms + 400ms = 900ms
async function slowDashboard() {
  const users    = await fetchUsers()     // 300ms
  const products = await fetchProducts() // 200ms — doesn't start until users done
  const orders   = await fetchOrders()   // 400ms — doesn't start until products done
  return { users, products, orders }
}

// ✓ Parallel — all three start simultaneously
// Total time: max(300ms, 200ms, 400ms) = 400ms
async function fastDashboard() {
  const [users, products, orders] = await Promise.all([
    fetchUsers(),
    fetchProducts(),
    fetchOrders(),
  ])
  return { users, products, orders }
}

// ✓ Parallel start, sequential use — a subtle but important pattern
async function smarter() {
  const userPromise    = fetchUsers()     // START immediately — no await
  const productPromise = fetchProducts()  // START immediately — no await

  const users    = await userPromise     // WAIT here
  const products = await productPromise  // Already running — may already be done
  return { users, products }
}

Async in loops — a minefield

const ids = [1, 2, 3, 4, 5]

// ❌ forEach doesn't work with async — forEach ignores returned Promises
ids.forEach(async (id) => {
  const user = await fetchUser(id)
  console.log(user.name)  // order unpredictable, no way to await completion
})
console.log('done')  // prints BEFORE any users are fetched

// ✓ for...of — sequential (one at a time, in order)
for (const id of ids) {
  const user = await fetchUser(id)  // waits for each before next
  console.log(user.name)
}
// Predictable order, but total time = sum of all fetches

// ✓ Promise.all with map — parallel (all at once)
const users = await Promise.all(ids.map(id => fetchUser(id)))
// All fetches start simultaneously — total time = slowest single fetch

// ✓ Controlled concurrency — batch of N at a time
async function fetchInBatches(ids, batchSize = 3) {
  const results = []
  for (let i = 0; i < ids.length; i += batchSize) {
    const batch = ids.slice(i, i + batchSize)
    const batchResults = await Promise.all(batch.map(id => fetchUser(id)))
    results.push(...batchResults)
  }
  return results
}

Async IIFE — top-level await without a module

// Before top-level await was supported (ES2022):
;(async () => {
  const data = await fetch('/api/config').then(r => r.json())
  initApp(data)
})()

// ES2022 modules — top-level await is now valid
// (in .mjs files or with "type": "module" in package.json)
const config = await fetch('/api/config').then(r => r.json())
initApp(config)

Async class methods and getters

class UserService {
  async getUser(id) {
    const res = await fetch(`/api/users/${id}`)
    if (!res.ok) throw new Error(`User ${id} not found`)
    return res.json()
  }

  async updateUser(id, data) {
    const res = await fetch(`/api/users/${id}`, {
      method: 'PUT',
      body: JSON.stringify(data),
      headers: { 'Content-Type': 'application/json' }
    })
    return res.json()
  }
}

// Note: async getters are not supported — this is a SyntaxError:
// async get currentUser() { ... }
// Use a regular async method instead

What async/await does NOT solve

// 1. Race conditions — still possible
let currentUser = null
async function loadUser(id) {
  const user = await fetchUser(id)
  currentUser = user  // if called twice quickly, second might overwrite first
}

// 2. Memory leaks from dangling Promises
async function componentMount() {
  const data = await fetchData()
  // If the component unmounted while awaiting, setting state here crashes React
  setState(data)  // "Can't perform state update on unmounted component"
}

// 3. Error swallowing — async functions without try/catch silently lose errors
async function silentFail() {
  const data = await mightFail()  // if this rejects, nothing catches it
  return transform(data)
}
silentFail()  // rejected Promise — goes unhandled

Common Misconceptions

⚠️

Many devs think async/await makes JavaScript blocking or synchronous — but actually the thread never blocks. When an await is hit, the async function suspends and the JavaScript thread is immediately free to run other code. The function resumes as a microtask when the awaited Promise settles. async/await is purely syntactic sugar — the runtime behavior is identical to .then() chains.

⚠️

Many devs think multiple await statements automatically run in parallel — but actually sequential awaits run one at a time, each waiting for the previous to complete before starting the next. This is the most expensive production mistake with async/await. To run operations in parallel, use Promise.all() and await the array result.

⚠️

Many devs think try/catch around await works differently than .catch() — but actually they are equivalent in what they catch. A try/catch block wrapping an await catches rejected Promises the same way .catch() does on a Promise chain. The difference is stylistic and structural, not behavioral.

⚠️

Many devs think await can be used anywhere in a function — but actually await is only valid inside a function marked async. Using await in a regular function or at the top level of a non-module script is a SyntaxError. Top-level await is only supported in ES modules (type="module").

⚠️

Many devs think async functions return the value they return — but actually every async function always returns a Promise, even if you write return 42. The value is wrapped. This is why calling an async function without await gives you a Promise object, not the value. The caller must also await or use .then().

⚠️

Many devs think forEach works fine with async callbacks — but actually forEach ignores the Promise returned by each async callback. The loop fires all callbacks and completes synchronously before any await inside them resolves. There is no way to await the completion of an async forEach. Use for...of for sequential, or Promise.all with .map() for parallel.

Where You'll See This in Real Code

Next.js server components and API routes are async functions by default — async function Page() and export async function GET() are idiomatic Next.js. The framework awaits the returned Promise and streams the result, and understanding that each await inside these functions suspends only that function (not the server) is critical for building performant Next.js apps.

React's useEffect can't be made async directly — useEffect(async () => {}) creates a Promise that React ignores, and the async function's cleanup return never works. The correct pattern is defining an async function inside the effect and immediately invoking it: useEffect(() => { const load = async () => { ... }; load() }, []). This is a direct consequence of how async functions always return Promises.

Prisma — the most popular Node.js ORM — has an entirely Promise/async-based API. Every database query (prisma.user.findMany(), prisma.post.create()) returns a Promise. Production Node.js backends using Prisma are almost entirely async/await code, and understanding sequential vs parallel await is the difference between a 3-second endpoint and a 300ms one.

Express.js doesn't catch errors in async route handlers by default — if an async handler throws, Express never sees the error and the request hangs. The fix is a wrapper: const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next). This is such a common problem that the express-async-errors package exists solely to patch it.

AbortController integrates with async/await for cancellation — const { signal } = new AbortController(); const data = await fetch(url, { signal }). When abort() is called, the awaited fetch rejects with an AbortError. This is the standard pattern for cancelling in-flight requests when React components unmount or when a user navigates away.

Playwright and Cypress (end-to-end testing frameworks) use async/await as their primary API — await page.goto(url), await page.click('button'), await expect(locator).toBeVisible(). Understanding that each await is a real asynchronous pause waiting for a real browser action is what makes writing reliable tests intuitive rather than confusing.

Interview Cheat Sheet

  • async function: always returns a Promise — return 42 becomes Promise.resolve(42)
  • await: pauses the async function only, never the thread — resumes as microtask
  • await non-Promise: equivalent to await Promise.resolve(value) — always fine
  • try/catch: catches rejected Promises from await — equivalent to .catch()
  • Sequential awaits: one at a time — each starts after previous completes
  • Parallel: await Promise.all([p1, p2, p3]) — all start simultaneously
  • forEach + async: broken — forEach ignores returned Promises
  • for...of + await: sequential in order — always works correctly
  • Promise.all + .map(): parallel — the correct loop pattern for concurrent ops
  • Top-level await: ES modules only (type="module")
💡

How to Answer in an Interview

  • 1.The sequential vs parallel distinction is the most commonly asked practical question — show the timing difference with concrete ms numbers
  • 2.The async forEach trap is asked constantly in output-prediction questions — know exactly why it fails
  • 3.async/await is sugar over Promises" — then prove it by showing the equivalent .then() chain
  • 4.Explain that async/await is syntactic sugar over Promises — same microtask queue
  • 5.Express async error handling is a great real-world problem that shows production experience
  • 6.The execution order question (1, A, 2, B) demonstrates you understand await's actual runtime behavior vs its appearance
📖 Deep Dive Articles
Promise.all vs allSettled vs race vs any: The Complete Comparison9 min readTop 50 JavaScript Interview Questions (With Deep Answers)18 min readModern JavaScript: ES6+ Features Every Developer Must Know13 min readJavaScript Error Handling: A Complete Guide to Errors, try/catch, and Async Failures11 min readJavaScript Performance Optimization: What Actually Makes Code Fast12 min readJavaScript Event Loop Explained: Call Stack, Microtasks, and Macrotasks11 min readJavaScript Promises & Async/Await: The Complete Mental Model12 min readJavaScript Output Questions: 30 Tricky Examples That Test Real Understanding14 min read

Practice Questions

8 questions
#01

Missing await causes wrong result

🟢 EasyAsync BugsDebug
#02

Sequential awaits killing performance

🟡 MediumAsync BugsDebug
#03

async/await execution order

🟡 MediumEvent Loop & Promises
#04

Swallowed error in async function

🟡 MediumAsync BugsDebug
#05

async/await vs .then chain

🔴 HardEvent Loop & Promises
#06

Multiple awaits

🔴 HardEvent Loop & Promises
#07

How does async/await work under the hood?

🟢 EasyAsync JS PRO💡 Syntactic sugar over Promises; await pauses the function, not the thread
#08

UI update blocked by sync code

🟡 MediumEvent Loop TrapsDebug

Related Topics

JavaScript Event Loop Interview Questions
Advanced·6–10 Qs
JavaScript Promise Interview Questions
Intermediate·7–12 Qs
JavaScript Error Handling Interview Questions
Intermediate·5–8 Qs
🎯

Can you answer these under pressure?

Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.

Practice Free →Try Output Quiz