Menu

JavaScript Async Await Promises ES2017 March 2026 ⏱ 15 min read

Async Await in JavaScript: How It Works With Examples

Learn what async await is, how it builds on Promises, how to handle errors with try/catch, how to run operations in parallel with Promise.all, and every common mistake that causes silent bugs in production JavaScript code.

Async await is the feature that finally made asynchronous JavaScript feel manageable. Before it existed, I spent more time debugging callback nesting and Promise chain errors than actually building things. Once I understood how async await works at a deep level, not just the syntax but the underlying Promise model it sits on top of, an entire category of JavaScript bugs disappeared from my code. This guide covers everything you need to reach that same level of understanding.

What Is Asynchronous JavaScript?

JavaScript runs on a single thread. It processes one instruction at a time. When your code tells JavaScript to fetch data from an API, read a file, or query a database, that operation does not complete instantly. It might take 200ms, 2 seconds, or longer. If JavaScript simply waited there doing nothing until the operation finished, the entire page would freeze. Buttons would stop working. Animations would halt. Users would see a locked browser.

Asynchronous code solves this. Instead of freezing while it waits, JavaScript hands the operation off to the browser or Node.js runtime, continues executing other code, and comes back to handle the result when the operation completes. The challenge, and the source of so much confusion, is writing code that handles the “come back later” part clearly and without bugs.

Asynchronous JavaScript is not hard because the concept is complex. It is hard because the early tools for handling it (callbacks and raw Promises) made simple sequential logic look like abstract art. Async await fixes that.

How JavaScript Async Handling Evolved: Callbacks to Promises to Async Await

To understand why async await exists, it helps to see the problem it solved. Consider three versions of the same logic: fetch a user, then fetch their orders, then fetch the first product in those orders.

Callbacks (2010s) — “callback hell”
fetchUser(userId, function(user) { fetchOrders(user.id, function(orders) { fetchProduct( orders[0].productId, function(product) { // Finally here console.log(product.name); }, function(err) { /* handle */ } ); }, function(err) { /* handle */ }); }, function(err) { /* handle */ });
Async Await (ES2017) — reads like English
async function getProductForUser(userId) { try { const user = await fetchUser(userId); const orders = await fetchOrders(user.id); const product = await fetchProduct( orders[0].productId ); console.log(product.name); } catch (err) { // One place handles all errors console.error(err); } }

The async await version reads as a linear sequence of steps, exactly how you would describe the logic in plain English. “Fetch the user. Then fetch their orders. Then fetch the product.” No nesting. One error handler. This is not just aesthetic: the flat structure genuinely reduces bugs because the flow is easier to reason about.

What Are Promises? The Foundation of Async Await

You cannot fully understand async await without understanding Promises, because async await is built on top of them. Under the hood, every async function returns a Promise. Every await expression unwraps a Promise. The syntax is different but the mechanics are identical.

A Promise is an object that represents the eventual result of an asynchronous operation. When you call a function that returns a Promise, you get back a Promise object immediately. The actual result (or error) comes later, when the async operation completes.

The Three States of a JavaScript Promise

Every Promise is always in one of exactly three states. Understanding these states is the key to understanding what happens when code awaits:

Pending
The async operation has started but has not finished yet. The Promise is waiting for a result.
Fulfilled
The operation completed successfully. The Promise has a resolved value you can use.
Rejected
The operation failed. The Promise carries an error reason that needs to be caught.

When you await a Promise, JavaScript pauses the current async function and waits for the Promise to move from Pending to either Fulfilled or Rejected. If Fulfilled, await returns the resolved value. If Rejected, await throws an error that your try/catch block catches.

JavaScript — Promises with .then() before async await existed
// A function that returns a Promise function fetchUser(id) { return fetch(`https://api.example.com/users/${id}`) .then(response => response.json()); } // Using the Promise with .then() and .catch() fetchUser(42) .then(user => console.log(user.name)) .catch(error => console.error(‘Failed:’, error.message)); // The same thing with async await — dramatically cleaner async function showUser(id) { try { const user = await fetchUser(id); console.log(user.name); } catch (error) { console.error(‘Failed:’, error.message); } }

The Async Await Syntax Explained

Async await introduced two keywords in ES2017. Understanding exactly what each one does eliminates most of the confusion developers have when first learning this pattern:

The async keyword goes before a function declaration and does two things. It marks the function as asynchronous, which allows the use of await inside it. And it automatically wraps the function’s return value in a Promise. If you return the string “hello” from an async function, the caller receives a Promise that resolves to “hello”, not the string directly.

The await keyword pauses the execution of the async function at that line until the Promise resolves, then returns the resolved value. It can only be used inside an async function (except at the top level of a module, which I cover later). It does not block the entire JavaScript thread, only the current async function. Other code continues running while the await is waiting.

Promise chaining with .then()
function getUser(id) { return fetch(`/api/users/${id}`) .then(res => res.json()) .then(user => user.name); } getUser(42).then(name => { console.log(name); });
Same logic with async await
async function getUser(id) { const res = await fetch(`/api/users/${id}`); const user = await res.json(); return user.name; } const name = await getUser(42); console.log(name);

Both versions do exactly the same thing. Both return a Promise. The async await version reads as a straightforward sequence: fetch, parse JSON, return the name. There is no chaining to follow. There is no mental stack of “what does this .then() receive from the previous .then()?” to maintain.

✅ An async function always returns a Promise

This trips up many developers when they first use async await. If you call an async function without using await, you get back a Promise, not the value you returned inside the function. const result = getUser(42) gives you a Promise. const result = await getUser(42) gives you the resolved string. Always await async functions unless you intentionally want the Promise itself (for example, passing it to Promise.all).

A Real-World Async Await Example: Chained API Calls

The scenario where async await provides the most dramatic improvement over earlier patterns is when multiple operations depend on each other in sequence. Fetch the user, use the user data to fetch their orders, use the order data to fetch a product. Each step depends on the result of the previous one.

Here is a complete, realistic example of how this looks in production JavaScript code:

JavaScript — chained async API calls
async function getUserOrderHistory(userId) { try { // Step 1: Fetch the user const userRes = await fetch(`/api/users/${userId}`); if (!userRes.ok) throw new Error(`User fetch failed: ${userRes.status}`); const user = await userRes.json(); // Step 2: Fetch their orders using the user’s customer ID const ordersRes = await fetch( `/api/orders?customerId=${user.customerId}` ); if (!ordersRes.ok) throw new Error(`Orders fetch failed: ${ordersRes.status}`); const orders = await ordersRes.json(); if (orders.length === 0) { return { user: user.name, orderCount: 0, lastProduct: null }; } // Step 3: Fetch the product from the most recent order const productRes = await fetch( `/api/products/${orders[0].productId}` ); if (!productRes.ok) throw new Error(`Product fetch failed: ${productRes.status}`); const product = await productRes.json(); return { user: user.name, orderCount: orders.length, lastProduct: product.name }; } catch (error) { console.error(‘getUserOrderHistory failed:’, error.message); return null; } }

Notice what makes this readable: each step has a clear variable name. The check for res.ok immediately after each fetch catches HTTP error responses (like 404 or 500) that fetch() does not automatically reject. This is a pattern I have had to add after debugging production issues where the API returned a 500 error and the code silently tried to parse it as JSON. The single try/catch at the bottom handles errors from any step in the chain.

Error Handling With Try/Catch in Async Functions

When a Promise is rejected, the await expression throws an error. Wrapping await calls in a try/catch block is how you handle that error. Without it, an unhandled rejection generates a warning in Node.js and fails silently in some browser contexts, which means your user sees nothing go wrong while data is lost or operations are skipped.

JavaScript — comprehensive async error handling
async function getUser(id) { try { const res = await fetch(`/api/users/${id}`); // fetch() only rejects on network errors, not HTTP error status codes // Always check res.ok for API calls if (!res.ok) { throw new Error(`API returned ${res.status}: ${res.statusText}`); } const user = await res.json(); return { success: true, user }; } catch (error) { // Network failure, non-OK response, or JSON parse error console.error(‘getUser failed:’, error.message); return { success: false, error: error.message }; } } // Calling the function const result = await getUser(42); if (result.success) { console.log(result.user.name); } else { showErrorMessage(result.error); }
⚠ fetch() does not reject on HTTP error responses

This catches out almost every developer the first time. The fetch() API only rejects its Promise when there is a network error (no connection, DNS failure, timeout). A 404, 500, or 403 response does not cause fetch() to reject. The Promise resolves with the response object, but res.ok will be false. Always check res.ok immediately after awaiting fetch() and throw an error manually if it is false. Failing to do this means your code silently tries to parse an error response as valid data.

Parallel Execution With Promise.all: When Awaiting Sequentially Is Too Slow

Awaiting operations one by one means each must fully complete before the next one starts. This sequential execution is correct when each step depends on the previous result. But it is unnecessarily slow when the operations are completely independent of each other. If fetching a user’s profile, their posts, and their followers are all independent API calls, there is no reason to wait for the profile before fetching the posts.

Slow Sequential await: 3 seconds total
async function sequentialFetch(id) { // Each waits for the previous const user = await fetchUser(id); // 1s const posts = await fetchPosts(id); // 1s const comments = await fetchComments(id); // 1s return { user, posts, comments }; } // Total time: ~3 seconds
Fast Promise.all: 1 second total
async function parallelFetch(id) { // All start simultaneously const [user, posts, comments] = await Promise.all([ fetchUser(id), fetchPosts(id), fetchComments(id) ]); return { user, posts, comments }; } // Total time: ~1 second

Promise.all takes an array of Promises and returns a new Promise that resolves when all of them resolve. The returned value is an array of results in the same order as the input array. If any one of the Promises rejects, the whole Promise.all rejects immediately with that error.

The rule to remember: if operation B needs the result of operation A, await them sequentially. If operations A, B, and C are all independent, run them with Promise.all. In practice, I have found that most dashboard and profile page loads can be dramatically sped up by switching from sequential awaits to Promise.all for the independent data fetches.

JavaScript — Promise.all with error handling
async function loadDashboard(userId) { try { // All three run in parallel — no waiting for one before starting the next const [profile, notifications, recentOrders] = await Promise.all([ fetch(`/api/users/${userId}/profile`).then(r => r.json()), fetch(`/api/users/${userId}/notifications`).then(r => r.json()), fetch(`/api/orders?userId=${userId}&limit=5`).then(r => r.json()) ]); return { profile, notifications, recentOrders }; } catch (error) { // If any request fails, the whole Promise.all rejects console.error(‘Dashboard load failed:’, error.message); return null; } } // For cases where partial failure is acceptable, use Promise.allSettled const results = await Promise.allSettled([ fetchProfile(userId), fetchNotifications(userId) ]); // Each result: { status: ‘fulfilled’, value: … } or { status: ‘rejected’, reason: … }
💡 Use Promise.allSettled when partial failure is acceptable

Promise.all fails fast: if any one Promise rejects, the whole thing rejects and you lose all the other results. Promise.allSettled waits for all Promises to settle (either fulfill or reject) and gives you an array of result objects describing the outcome of each. Use Promise.allSettled when you want to load a dashboard and show whatever data succeeded, rather than showing nothing because one optional data source failed.

Common Async Await Mistakes That Cause Silent Bugs

These are the mistakes I have seen repeatedly in code reviews and in production bugs. Each one is subtle enough that it does not throw an obvious error: instead it produces wrong behaviour that is surprisingly hard to trace back to the root cause.

1
Forgetting to await a Promise

The most common async await mistake. You call an async function, forget the await keyword, and get back a Promise object instead of the resolved value. When you then try to access a property on that “value,” you get undefined because Promises do not have your data as properties.

Bug: data is a Promise, not a user
async function showUser(id) { // Missing await! const data = fetchUser(id); // data is a Promise object // data.name is undefined console.log(data.name); // undefined }
Fixed: await the function call
async function showUser(id) { // Correctly awaited const data = await fetchUser(id); // data is now the resolved user object console.log(data.name); // “Alex” }
Add await before every async function call where you need the resolved value. If your IDE shows a TypeScript or ESLint warning about floating Promises, that is exactly this bug being caught at development time.
2
Awaiting in a loop when parallel execution is possible

Using await inside a for loop makes each iteration wait for the previous to finish before starting the next. When the loop processes independent items, this multiplies the total time by the number of items. Ten independent API calls that each take 200ms become 2000ms sequentially instead of 200ms in parallel.

Slow: awaiting in a for loop
async function getUsersSlowly(ids) { const users = []; for (const id of ids) { // Waits for each before next starts const user = await fetchUser(id); users.push(user); } return users; } // 10 users x 200ms = 2000ms
Fast: Promise.all for parallel fetches
async function getUsersFast(ids) { // All fetches start simultaneously return Promise.all( ids.map(id => fetchUser(id)) ); } // 10 users in parallel = ~200ms
Use Promise.all(array.map(item => asyncFn(item))) for independent items. Keep sequential for…of with await only when each item depends on the result of processing the previous item.
3
Not handling errors in async functions

An async function without error handling that throws will result in an unhandled Promise rejection. In Node.js this produces a warning and in future versions may crash the process. In browsers it fails silently and downstream code receives undefined where it expected data, producing confusing secondary errors far from the root cause.

Dangerous: no error handling
async function createOrder(data) { // If this throws, nothing catches it const order = await saveToDatabase(data); await sendConfirmationEmail(order); return order; } // Caller gets undefined on failure
Safe: consistent error handling
async function createOrder(data) { try { const order = await saveToDatabase(data); await sendConfirmationEmail(order); return { success: true, order }; } catch (error) { return { success: false, error: error.message }; } } // Caller always gets a clear result shape
Wrap the body of every async function in try/catch. Return a consistent result shape for both success and failure so the caller always knows what to expect.
4
Using async await with forEach

Array.prototype.forEach does not understand async functions. If you pass an async callback to forEach, it fires all the callbacks but does not wait for the Promises they return. The await inside the callback works, but the forEach call itself completes immediately and the awaiting happens invisibly in the background.

Bug: forEach does not await
// This does NOT work as expected async function processOrders(orders) { orders.forEach(async (order) => { await processOrder(order); }); // Returns before any order is processed }
Fixed: use for…of or Promise.all
// Sequential: for…of async function processOrders(orders) { for (const order of orders) { await processOrder(order); } } // Parallel: Promise.all async function processOrders(orders) { await Promise.all( orders.map(order => processOrder(order)) ); }
Never use async callbacks with forEach. Use for…of for sequential async iteration, or Promise.all with .map() for parallel async iteration.

Using Async Await in Different JavaScript Contexts

Async await syntax works everywhere functions work, but a few specific contexts have their own patterns worth knowing:

📄 Top-level await (ES modules)
// In an ES module (.mjs or type=”module”) // You can await at the top level const config = await loadConfig(); const app = createApp(config); app.listen(3000);
⚛️ React useEffect
// useEffect cannot be async directly useEffect(() => { async function loadData() { const data = await fetchData(); setData(data); } loadData(); // Call it immediately }, []);
🚀 Express.js route handlers
app.get(‘/users/:id’, async (req, res) => { try { const user = await getUser(req.params.id); res.json(user); } catch (err) { res.status(500).json({ error: err.message }); } } );
🟢 Node.js with async file reading
const fs = require(‘fs’).promises; async function readConfig(path) { try { const content = await fs.readFile(path, ‘utf8’); return JSON.parse(content); } catch (err) { return null; } }

7-Step Guide to Mastering Async Await in JavaScript

If you are learning async await or helping someone else learn it, this is the sequence that builds genuine understanding rather than just pattern memorisation:

  1. Understand what a Promise is before writing any async await syntax. Open your browser console and create a Promise manually: const p = new Promise((resolve, reject) => setTimeout(() => resolve(‘done’), 1000)). Watch its state in the console. Call p.then(v => console.log(v)). See the pending state become fulfilled. Async await is a layer on top of this: every await you write is doing exactly what .then() does, with cleaner syntax.
  2. Write your first async function that fetches real data. Use the browser console or a simple Node.js file. Write async function getPost() { const res = await fetch(‘https://jsonplaceholder.typicode.com/posts/1’); const post = await res.json(); console.log(post.title); } and call it. See the data appear. The JSONPlaceholder API is a free testing API perfect for this. Understanding the fetch to await res.json() pattern is the single most common real-world async await usage.
  3. Practice error handling by deliberately causing failures. Change the URL in step 2 to something that returns a 404. Add if (!res.ok) throw new Error(res.status) after the fetch and wrap everything in try/catch. Log the error. Change the URL to something completely invalid (not a URL at all) and see the different kind of error that generates. Understanding the two failure modes (network error vs HTTP error status) is essential for robust async code.
  4. Compare sequential vs parallel timing with Promise.all. Fetch two different endpoints sequentially with two await calls and use console.time to measure the total time. Then fetch the same two endpoints with Promise.all and measure again. See the difference. This hands-on comparison makes the performance argument for Promise.all immediately concrete and memorable.
  5. Debug your first async mistake intentionally. Write the forEach with async callback mistake from the mistakes section above. Run it and observe that it completes before the async operations finish. Then rewrite it with for…of and observe the correct behaviour. Deliberately introducing and then fixing mistakes is the fastest way to build the muscle memory that prevents them in production.
  6. Write an async function in the context you actually work in. If you use React, write a useEffect that correctly loads async data using the inner-function pattern. If you use Express, write a route handler that queries a database with async await and returns the result. If you use Node.js scripts, read a JSON file asynchronously. Applying the pattern in your real context builds familiarity faster than abstract exercises.
  7. When async API responses differ between environments, use the Text Diff Checker to find the difference. One situation I have encountered repeatedly is async functions that work correctly in development but return subtly different data in staging or production. The structure looks the same but a field is null, an array is empty, or a nested object has a different shape. Paste the development response and the production response side by side into the Text Diff Checker and every difference is highlighted immediately. This is the fastest way to diagnose environment-specific async data issues.

Frequently Asked Questions About Async Await in JavaScript

What is the difference between async await and Promises?

There is no fundamental difference in what they do: async await is syntactic sugar built on top of Promises. Under the hood, an async function returns a Promise and every await expression is equivalent to calling .then() on a Promise. The difference is readability and error handling. With Promises, sequential operations require chaining .then() calls, which become hard to follow for more than two or three operations. With async await, the same sequential logic reads as a flat sequence of statements. Error handling is also significantly simpler with async await: one try/catch block handles all errors in a sequence, whereas Promise chains require careful placement of .catch() at each point where errors can occur.

Can I use await outside of an async function?

In modern JavaScript (ES2022 and later), you can use await at the top level of an ES module without wrapping it in an async function. This is called top-level await and is supported in Node.js 14.8 and later (with “type”: “module” in package.json) and in all modern browsers when the script tag has type=”module”. Outside of a module context (in a CommonJS Node.js file or a regular browser script tag), await can only be used inside an async function. Using await in a non-async function throws a SyntaxError.

Does await block the entire JavaScript thread?

No, and this is one of the most important things to understand about async await. When a function hits an await, it pauses that specific async function and returns control to the JavaScript event loop. Other code continues running: other event handlers fire, other Promises resolve, the UI remains responsive. Only the current async function is paused. This is completely different from a synchronous blocking operation (like a while loop that runs for several seconds), which does freeze the entire thread. This is why async await is the correct tool for I/O-bound operations (network requests, file reads) but not appropriate for CPU-bound operations (complex calculations), which should be moved to a Web Worker.

What is the difference between Promise.all and Promise.allSettled?

Promise.all rejects immediately if any one of its input Promises rejects, discarding the results of all other Promises even if they succeeded. Promise.allSettled waits for all Promises to complete regardless of whether they fulfill or reject, then returns an array of result objects where each object describes the outcome of the corresponding Promise: either { status: ‘fulfilled’, value: … } or { status: ‘rejected’, reason: … }. Use Promise.all when you need all operations to succeed for the result to be useful. Use Promise.allSettled when you want the results of all operations that succeeded, even if some failed, for example loading a dashboard where some widgets can still show even if others fail to load.

Why does await not work inside a forEach callback?

Array.prototype.forEach calls its callback synchronously for each item and does not inspect the return value. When you pass an async callback, each call to the callback returns a Promise, but forEach ignores those Promises completely. This means forEach finishes iterating immediately, and the async operations run in the background without anything waiting for them. Your code continues executing after the forEach before any of the async callbacks have completed. The fix is to use for…of with await for sequential processing, or Promise.all(array.map(…)) for parallel processing, both of which correctly wait for all async operations to complete before continuing.

How do I handle errors in async functions called without await?

When you call an async function without awaiting it, you get back a Promise. If that Promise rejects and you have not attached a .catch() handler to it, the rejection is unhandled. In Node.js 15 and later, unhandled Promise rejections crash the process. In older Node.js and in browsers, they generate a warning but do not crash. The solutions depend on your intent. If you genuinely want to fire and forget, attach a catch handler: doSomethingAsync().catch(err => console.error(err)). If you want to process the result later, store the Promise and await it when you need it. If you just forgot the await, add it. The best way to prevent this mistake is to enable the ESLint rule @typescript-eslint/no-floating-promises, which flags every unawaited async function call as an error.

Free browser-based developer tools

Tools for working with async API responses

Format and inspect JSON responses from your async fetch calls, compare API responses between environments, convert data formats, and more. All free, all in your browser, no login required.

Async Await Is Not Magic. Once You See the Promise Underneath, It All Clicks.

The developers who get confused by async await are almost always the ones who learned the syntax without understanding Promises first. Once you know that async await is just Promises with cleaner syntax, every behaviour makes sense: why async functions always return Promises, why you cannot use await outside async functions, why forEach does not work with async callbacks, why forgetting await gives you a Promise object instead of your data.

The four habits that eliminate most async await bugs: always check res.ok after fetch(), always wrap async function bodies in try/catch, always use Promise.all for independent parallel operations, and never use async callbacks with forEach. Build those four habits and most of the async JavaScript bugs you encounter in code review will be in other people’s code, not yours.

When you are debugging async API responses and need to see what data is actually coming back, paste the response into the JSON Formatter to see the exact structure clearly. When responses differ between your development and production environments, use the Text Diff Checker to find every difference in seconds rather than staring at two JSON blobs trying to spot the discrepancy.