Learn async/await in JavaScript. Write cleaner async code with try/catch error handling, Promise.all for parallel execution, and more.
Why does asynchronous code have to look so complicated? What if you could write code that fetches data from a server, waits for user input, or reads files, all while looking as clean and readable as regular synchronous code?
Copy
Ask AI
// This is async code that reads like sync codeasync function getUserData(userId) { const response = await fetch(`/api/users/${userId}`) const user = await response.json() return user}// Using the async function(async () => { const user = await getUserData(123) console.log(user.name) // "Alice"})()
That’s the magic of async/await. It’s syntactic sugar introduced in the ECMAScript 2017 specification that makes asynchronous JavaScript look and behave like synchronous code, while still being non-blocking under the hood. According to the 2023 State of JS survey, async/await has become the most widely used async pattern, adopted by over 90% of JavaScript developers.
What you’ll learn in this guide:
What async/await actually is (and why it’s “just” Promises underneath)
How the async keyword transforms functions into Promise-returning functions
How await pauses execution without blocking the main thread
Error handling with try/catch (finally, a sane way to handle async errors!)
The critical difference between sequential and parallel execution
The most common async/await mistakes and how to avoid them
How async/await relates to the event loop and microtasks
Prerequisites: This guide assumes you understand Promises. async/await is built entirely on top of them. You should also be familiar with the Event Loop to understand why code after await behaves like a microtask.
Think of async/await as a friendlier way to write Promises. You mark a function with async, use await to pause until a Promise resolves, and your async code suddenly reads like regular synchronous code. The best part? JavaScript stays non-blocking under the hood.Here’s the same operation written three ways:
Callbacks (Old Way)
Promises
async/await (Modern)
Copy
Ask AI
// Callback hell - nested so deep you need a flashlightfunction getUserPosts(userId, callback) { fetchUser(userId, (err, user) => { if (err) return callback(err) fetchPosts(user.id, (err, posts) => { if (err) return callback(err) fetchComments(posts[0].id, (err, comments) => { if (err) return callback(err) callback(null, { user, posts, comments }) }) }) })}
The async/await version is much easier to read. Each line clearly shows what happens next, error handling uses familiar try/catch, and there’s no nesting or callback pyramids. As documented on MDN, every async function implicitly returns a Promise, making it fully compatible with existing Promise-based APIs.
Don’t forget: async/await doesn’t replace Promises. It’s built on top of them. Every async function returns a Promise, and await works with any Promise. The better you understand Promises, the better you’ll be at async/await.
Think of async/await like ordering food at a restaurant with table service versus a fast-food counter.Without async/await (callback style): You order at the counter, then stand there awkwardly blocking everyone behind you until your food is ready. If you need multiple items, you wait for each one before ordering the next.With async/await: You sit at a table and place your order. The waiter takes it to the kitchen (starts the async operation), but you’re free to chat, check your phone, or do other things (the main thread isn’t blocked). When the food is ready, the waiter brings it to you (the Promise resolves) and you continue from where you left off.
Copy
Ask AI
┌─────────────────────────────────────────────────────────────────────────┐│ THE RESTAURANT ANALOGY │├─────────────────────────────────────────────────────────────────────────┤│ ││ async function dinner() { ││ ││ ┌──────────┐ "I'll have the ┌─────────────┐ ││ │ YOU │ ──────────────────────► │ KITCHEN │ ││ │ (code) │ pasta please" │ (server) │ ││ └──────────┘ await order() └─────────────┘ ││ │ │ ││ │ You're free to do │ Kitchen is ││ │ other things while │ preparing... ││ │ waiting! │ ││ │ │ ││ │ "Your pasta!" │ ││ ┌──────────┐ ◄────────────────────── ┌─────────────┐ ││ │ YOU │ Promise resolved │ KITCHEN │ ││ │ resume │ │ done │ ││ └──────────┘ └─────────────┘ ││ ││ return enjoyMeal(pasta) ││ } ││ ││ The KEY: You (the main thread) are NOT blocked while waiting! ││ Other customers (other code) can be served. ││ │└─────────────────────────────────────────────────────────────────────────┘
Here’s the clever part: await makes your code look like it’s waiting, but JavaScript is actually free to do other work. When the Promise resolves, your function resumes exactly where it left off.
When you return a value from an async function, it gets automatically wrapped in Promise.resolve():
Copy
Ask AI
async function getValue() { return 42}// The above is equivalent to:function getValuePromise() { return Promise.resolve(42)}// Both work the same way:getValue().then(value => console.log(value)) // 42
When you throw an error in an async function, it becomes a rejected Promise:
Copy
Ask AI
async function failingFunction() { throw new Error('Something went wrong!')}// The above is equivalent to:function failingPromise() { return Promise.reject(new Error('Something went wrong!'))}// Both are caught the same way:failingFunction().catch(err => console.log(err.message)) // "Something went wrong!"
If you return a Promise from an async function, it doesn’t get double-wrapped:
Copy
Ask AI
async function fetchData() { // Returning a Promise directly - it's NOT double-wrapped return fetch('/api/data')}// This returns Promise<Response>, NOT Promise<Promise<Response>>const response = await fetchData()
You can use async with function expressions and arrow functions too:
Copy
Ask AI
// Async function expressionconst fetchData = async function() { return await fetch('/api/data')}// Async arrow functionconst loadData = async () => { return await fetch('/api/data')}// Async arrow function (concise body)const getData = async () => fetch('/api/data')// Async method in an objectconst api = { async fetchUser(id) { return await fetch(`/api/users/${id}`) }}// Async method in a classclass UserService { async getUser(id) { const response = await fetch(`/api/users/${id}`) return response.json() }}
Common misconception: Making a function async doesn’t make it run in a separate thread or “in the background.” JavaScript is still single-threaded. The async keyword simply enables the use of await inside the function and ensures it returns a Promise.
The await keyword is where things get interesting. It pauses the execution of an async function until a Promise settles (fulfills or rejects), then resumes with the resolved value.
Copy
Ask AI
async function example() { console.log('Before await') const result = await somePromise() // Execution pauses here console.log('After await:', result) // Resumes when Promise resolves}
At the top level of an ES module (top-level await, covered later)
Copy
Ask AI
// ✓ Inside async functionasync function fetchUser() { const response = await fetch('/api/user') return response.json()}// ✓ Top-level await in ES modules// (in a .mjs file or with "type": "module" in package.json)const config = await fetch('/config.json').then(r => r.json())// ❌ NOT in regular functionsfunction regularFunction() { const data = await fetch('/api/data') // SyntaxError!}// ❌ NOT in global scope of scripts (non-modules)await fetch('/api/data') // SyntaxError in non-module scripts
You can await any value, but it’s most useful with Promises:
Copy
Ask AI
// Awaiting a Promise (the normal case)const response = await fetch('/api/data')// Awaiting Promise.resolve()const value = await Promise.resolve(42)console.log(value) // 42// Awaiting a non-Promise value (works, but pointless)const num = await 42console.log(num) // 42 (immediately, no actual waiting)// Awaiting a thenable (object with .then method)const thenable = { then(resolve) { setTimeout(() => resolve('thenable value'), 1000) }}const result = await thenableconsole.log(result) // "thenable value" (after 1 second)
Pro tip: Only use await when you’re actually waiting for a Promise. Awaiting non-Promise values works but adds unnecessary overhead and confuses anyone reading your code.
Technical detail: Even when awaiting an already-resolved Promise or a non-Promise value, execution still pauses until the next microtask. This is why await always yields control back to the caller before continuing.
Let’s peek under the hood at what actually happens. When you await a Promise, the code after the await becomes a microtask that runs when the Promise resolves.
Copy
Ask AI
async function example() { console.log('1. Before await') // Runs synchronously await Promise.resolve() console.log('2. After await') // Runs as a microtask}console.log('A. Before call')example()console.log('B. After call')// Output:// A. Before call// 1. Before await// B. After call// 2. After await
Let’s trace through this step by step:
1
Synchronous code starts
console.log('A. Before call') executes → prints “A. Before call”
2
Call example()
The function starts executing synchronously.
console.log('1. Before await') executes → prints “1. Before await”
3
Hit the await
await Promise.resolve(). The Promise is already resolved, but the code after await is still scheduled as a microtask. The function pauses and returns control to the caller.
4
Continue after the call
console.log('B. After call') executes → prints “B. After call”
5
Call stack empties, microtasks run
The event loop processes the microtask queue. The continuation of example() runs.
console.log('2. After await') executes → prints “2. After await”
Copy
Ask AI
┌─────────────────────────────────────────────────────────────────────────┐│ await SPLITS THE FUNCTION │├─────────────────────────────────────────────────────────────────────────┤│ ││ async function example() { ││ console.log('Before') ──────► Runs SYNCHRONOUSLY ││ ││ await somePromise() ──────► PAUSE: Schedule continuation ││ as microtask, return to caller ││ ││ console.log('After') ──────► Runs as MICROTASK when ││ } Promise resolves ││ ││ ───────────────────────────────────────────────────────────────────── ││ ││ Think of it like this - await transforms the function into: ││ ││ function example() { ││ console.log('Before') ││ return somePromise().then(() => { ││ console.log('After') ││ }) ││ } ││ │└─────────────────────────────────────────────────────────────────────────┘
This is why understanding the Event Loop is so important for async/await. The await keyword effectively registers a microtask, which has priority over setTimeout callbacks (macrotasks).
Finally, error handling that doesn’t make you want to flip a table. Instead of chaining .catch() after .then() after .catch(), you get to use good old try/catch blocks.
async function fetchUserData(userId) { try { const response = await fetch(`/api/users/${userId}`) if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`) } const user = await response.json() return user } catch (error) { console.error('Failed to fetch user:', error.message) throw error // Re-throw if you want callers to handle it }}
Both approaches work, but they have different use cases:
try/catch (Preferred)
.catch() (Sometimes Better)
Copy
Ask AI
// Good for: Multiple awaits where any could failasync function getFullProfile(userId) { try { const user = await fetchUser(userId) const posts = await fetchPosts(userId) const friends = await fetchFriends(userId) return { user, posts, friends } } catch (error) { // Catches any of the three failures console.error('Profile fetch failed:', error) return null }}
Copy
Ask AI
// Good for: Handling errors for specific operationsasync function getProfileWithFallback(userId) { const user = await fetchUser(userId) // Only this operation has fallback behavior const posts = await fetchPosts(userId).catch(() => []) // This will still throw if it fails const friends = await fetchFriends(userId) return { user, posts, friends }}
Promise.all fails fast. If any Promise rejects, the whole thing rejects.Promise.allSettled waits for all Promises and gives you results for each (fulfilled or rejected).
Copy
Ask AI
// Promise.all - fails fastasync function getAllOrNothing() { try { const results = await Promise.all([ fetchUser(1), fetchUser(999), // This one fails fetchUser(3) ]) return results } catch (error) { // If ANY request fails, we end up here console.log('At least one request failed') }}// Promise.allSettled - get all results regardless of failuresasync function getAllResults() { const results = await Promise.allSettled([ fetchUser(1), fetchUser(999), // This one fails fetchUser(3) ]) // results = [ // { status: 'fulfilled', value: user1 }, // { status: 'rejected', reason: Error }, // { status: 'fulfilled', value: user3 } // ] const successful = results .filter(r => r.status === 'fulfilled') .map(r => r.value) const failed = results .filter(r => r.status === 'rejected') .map(r => r.reason) return { successful, failed }}
Sometimes you need a mix: some operations depend on others, but independent ones can run in parallel:
Copy
Ask AI
async function processOrder(orderId) { // Step 1: Must fetch order first const order = await fetchOrder(orderId) // Step 2: These can run in parallel (both depend on order, not each other) const [inventory, pricing] = await Promise.all([ checkInventory(order.items), calculatePricing(order.items) ]) // Step 3: Must wait for both before charging const payment = await processPayment(order, pricing) // Step 4: These can run in parallel (both depend on payment) const [receipt, notification] = await Promise.all([ generateReceipt(payment), sendConfirmationEmail(order, payment) ]) return { order, payment, receipt }}
Without await, you get a Promise object instead of the resolved value.
Copy
Ask AI
// ❌ WRONG - response is a Promise, not a Response!async function fetchUser() { const response = fetch('/api/user') // Missing await! const data = response.json() // Error: response.json is not a function return data}// ✓ CORRECTasync function fetchUser() { const response = await fetch('/api/user') const data = await response.json() return data}
The silent bug: Sometimes forgetting await doesn’t throw an error. You just get unexpected results. If you see [object Promise] in your output or undefined where you expected data, check for missing awaits.
forEach and async don’t play well together. It just fires and forgets:
Copy
Ask AI
// ❌ WRONG - forEach doesn't await!async function processUsers(userIds) { userIds.forEach(async (id) => { const user = await fetchUser(id) console.log(user.name) }) console.log('Done!') // Prints BEFORE users are fetched!}// ✓ CORRECT - Use for...of for sequentialasync function processUsersSequential(userIds) { for (const id of userIds) { const user = await fetchUser(id) console.log(user.name) } console.log('Done!') // Prints after all users}// ✓ CORRECT - Use Promise.all for parallelasync function processUsersParallel(userIds) { await Promise.all( userIds.map(async (id) => { const user = await fetchUser(id) console.log(user.name) }) ) console.log('Done!') // Prints after all users}
Mistake #3: Sequential await When Parallel is Better
We covered this above, but it’s worth repeating:
Copy
Ask AI
// ❌ SLOW - 3 seconds totalasync function getData() { const a = await fetchA() // 1 second const b = await fetchB() // 1 second const c = await fetchC() // 1 second return { a, b, c }}// ✓ FAST - 1 second totalasync function getData() { const [a, b, c] = await Promise.all([ fetchA(), fetchB(), fetchC() ]) return { a, b, c }}
Unhandled Promise rejections can crash your application.
Copy
Ask AI
// ❌ WRONG - No error handlingasync function riskyOperation() { const data = await fetch('/api/might-fail') return data.json()}// If fetch fails, we get an unhandled rejectionriskyOperation() // No .catch(), no try/catch// ✓ CORRECT - Handle errorsasync function safeOperation() { try { const data = await fetch('/api/might-fail') return data.json() } catch (error) { console.error('Operation failed:', error) return null // Or throw, or return error object }}// Or catch at the call siteriskyOperation().catch(err => console.error('Failed:', err))
Mistake #5: Missing await Before return in try/catch
If you want to catch errors from a Promise inside a try/catch, you must use await. Without it, the Promise is returned before it settles, and the catch block never runs:
Copy
Ask AI
// ❌ WRONG - catch block won't catch fetch errors!async function fetchData() { try { return fetch('/api/data') // Promise returned before it settles } catch (error) { // This NEVER runs for fetch errors! console.error('Error:', error) }}// ✓ CORRECT - await lets catch block handle errorsasync function fetchData() { try { return await fetch('/api/data') // await IS needed here } catch (error) { console.error('Error:', error) throw error }}
Why does this happen? When you return fetch(...) without await, the Promise is immediately returned to the caller. If that Promise later rejects, the rejection happens outside the try/catch block, so the catch never sees it.
Common misconception: Some guides say return await is redundant. That’s only true outside of try/catch blocks. Inside try/catch, you need await to catch errors from the Promise.
Copy
Ask AI
// Outside try/catch, these ARE equivalent:async function noTryCatch() { return await fetch('/api/data') // await is optional here}async function noTryCatchSimpler() { return fetch('/api/data') // Same result, slightly cleaner}// But inside try/catch, they behave DIFFERENTLY:async function withTryCatch() { try { return await fetch('/api/data') // Errors ARE caught } catch (e) { /* handles errors */ }}async function brokenTryCatch() { try { return fetch('/api/data') // Errors NOT caught! } catch (e) { /* never runs for fetch errors */ }}
// Promise chain is more concise for simple transformationsfetchUser(id) .then(user => user.profileId) .then(fetchProfile) .then(profile => profile.avatarUrl)// async/await equivalent - more verboseasync function getAvatarUrl(id) { const user = await fetchUser(id) const profile = await fetchProfile(user.profileId) return profile.avatarUrl}// Promise.race is cleaner with raw Promisesconst result = await Promise.race([ fetch('/api/main'), timeout(5000)])// Promise chain for "fire and forget"saveAnalytics(data).catch(console.error) // Don't await, just catch errors
Careful: Top-level await blocks the loading of the module and any modules that import it. Use it sparingly, only when you truly need the value before the module can be used.
async function* generateAsyncNumbers() { for (let i = 1; i <= 5; i++) { await new Promise(resolve => setTimeout(resolve, 1000)) yield i }}// Consume the async iteratorasync function processNumbers() { for await (const num of generateAsyncNumbers()) { console.log(num) // Prints 1, 2, 3, 4, 5 (one per second) }}
// Version Aasync function versionA() { const start = Date.now() const a = await delay(1000) const b = await delay(1000) console.log(`Time: ${Date.now() - start}ms`)}// Version Basync function versionB() { const start = Date.now() const [a, b] = await Promise.all([delay(1000), delay(1000)]) console.log(`Time: ${Date.now() - start}ms`)}function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms))}
Answer
versionA: ~2000ms (sequential — waits 1s, then another 1s)versionB: ~1000ms (parallel — both delays run simultaneously)This is the classic “sequential vs parallel” interview question. In versionA, each await must complete before the next line runs. In versionB, both Promises are created immediately, then Promise.all waits for both to complete while they run in parallel.
async function outer() { try { await inner() console.log('After inner') } catch (e) { console.log('Caught:', e.message) }}async function inner() { throw new Error('Oops!')}outer()
Answer
Output:Caught: Oops!“After inner” is never printed because inner() throws, which causes the await inner() to reject, which jumps to the catch block.This demonstrates that async/await error handling works like synchronous try/catch. Errors “propagate up” naturally.
(Not 1, 2, 3, Done as you might expect!)Why:forEach doesn’t wait for async callbacks. It fires off all three async functions and immediately continues to console.log('Done'). The numbers print later when their delays complete.Fix: Use for...of for sequential or Promise.all with map for parallel.
Issue: The catch block will never catch fetch errors.When you return fetch(...) without await, the Promise is returned before it settles. If the fetch later fails, the rejection happens outside the try/catch block.
Copy
Ask AI
// ❌ WRONG - catch never runs for fetch errorsasync function getData() { try { return fetch('/api/data') // Promise returned immediately } catch (error) { console.error('Failed:', error) // Never runs! return null }}// ✓ CORRECT - await lets catch block handle errorsasync function getData() { try { return await fetch('/api/data') // await IS needed } catch (error) { console.error('Failed:', error) // Now this runs on error return null }}
Note: Outside of try/catch, return await and return behave the same. The await only matters when you need to catch errors or do something with the value before returning.
Question 2: What's the difference between these two?
Copy
Ask AI
// Version Aconst data = await fetchData()// Version Bconst data = fetchData()
Answer:
Version A:data contains the resolved value (e.g., the actual JSON object)
Version B:data contains a Promise object, not the resolved value
Version B is a common mistake that leads to bugs like seeing [object Promise] or getting undefined properties.
Question 3: How do you run async operations in parallel?
Answer:Use Promise.all() to run multiple async operations simultaneously:
Copy
Ask AI
// ❌ Sequential (slow)const a = await fetchA()const b = await fetchB()const c = await fetchC()// ✓ Parallel (fast)const [a, b, c] = await Promise.all([ fetchA(), fetchB(), fetchC()])
For cases where you want all results even if some fail, use Promise.allSettled().
Question 4: Why doesn't await work inside forEach?
Answer:forEach is not async-aware. It doesn’t wait for the callback’s Promise to resolve before continuing. It just fires off all the async callbacks and moves on.
Copy
Ask AI
// ❌ Doesn't waititems.forEach(async item => { await processItem(item)})console.log('Done') // Prints before items are processed!// ✓ Sequential - use for...offor (const item of items) { await processItem(item)}console.log('Done') // Prints after all items// ✓ Parallel - use Promise.all with mapawait Promise.all(items.map(item => processItem(item)))console.log('Done') // Prints after all items
Question 5: How do you handle errors in async functions?
Answer:Use try/catch blocks, which work just like synchronous error handling:
Copy
Ask AI
async function fetchData() { try { const response = await fetch('/api/data') if (!response.ok) { throw new Error(`HTTP ${response.status}`) } return await response.json() } catch (error) { console.error('Fetch failed:', error) throw error // Re-throw if caller should handle it } finally { // Cleanup code that always runs }}
You can also use .catch() at the call site: fetchData().catch(handleError)
Async/await is syntactic sugar introduced in ECMAScript 2017 that makes asynchronous code look and behave like synchronous code. The async keyword marks a function as returning a Promise, and await pauses execution until that Promise settles. Under the hood, it is still using Promises — await is equivalent to calling .then() on the awaited value.
Is async/await better than using Promises directly?
Async/await is generally more readable, especially for sequential operations and error handling with try/catch. However, raw Promise methods like Promise.all() are still essential for parallel execution. According to the 2023 State of JS survey, async/await is the most widely used async pattern among JavaScript developers, but both approaches have their place.
How do you handle errors with async/await?
Wrap your await calls in a try/catch block. The catch block receives the rejection reason, just like .catch() in Promise chains. You can also add a finally block for cleanup logic. This is one of the biggest advantages of async/await — error handling uses the same familiar syntax as synchronous code.
What is the difference between sequential and parallel async execution?
Sequential execution uses await on each call one after another — each waits for the previous to complete. Parallel execution uses Promise.all([...]) to start multiple operations simultaneously. Parallel is faster when operations are independent. A common mistake is accidentally writing sequential code when parallel would be appropriate.
Can you use await at the top level of a module?
Yes. Top-level await was standardized in ECMAScript 2022 and works in ES modules (files with type="module" or .mjs extension). It lets you await Promises at the module’s top scope without wrapping them in an async function. This is useful for dynamic imports, configuration loading, and module initialization.