JavaScript async/await

async and await are keywords that let you write asynchronous, promise-based code as if it were synchronous. Instead of chaining .then() callbacks, you write code that reads top-to-bottom, while the JavaScript engine still runs it without blocking the main thread. Under the hood, async/await is not a new concurrency model — it is syntax sugar built entirely on top of promises and the event loop. Understanding that connection is the key to using it correctly and avoiding its most common traps.

Overview: How async/await Works

Every function declared with the async keyword automatically returns a promise, even if you never write Promise anywhere inside it. If the function returns a plain value, that value becomes the resolved value of the returned promise. If the function throws (or a value it awaits rejects), the returned promise rejects with that error. This single guarantee — an async function always hands you a promise — is what makes await possible.

Inside an async function, the await keyword can be placed before any expression that produces a promise (or any value at all — non-promises are automatically wrapped via Promise.resolve()). When the engine hits an await, it does the following: it suspends execution of that async function at that exact point, immediately returns control to whoever called the function (so the rest of your program keeps running), and registers a continuation that will resume the function body once the awaited promise settles. This resumption is scheduled as a microtask, meaning it runs after the current synchronous code finishes but before the browser or Node repaints, handles I/O, or processes timers (which live in the macrotask queue).

This is why await never blocks the JavaScript thread. The call stack empties while waiting, other code (event handlers, other promises, timers) gets a chance to run, and only when the awaited value resolves does the engine push the paused function’s continuation back onto the call stack via the microtask queue. Conceptually, await somePromise is very close to writing somePromise.then(value => { /* rest of the function */ }), except the language handles the callback wiring, error propagation, and control flow for you.

Syntax

async function functionName(param1, param2) {
  const result1 = await Promise.resolve(param1 * 2);
  const result2 = await Promise.resolve(param2 * 3);
  return result1 + result2;
}

functionName(2, 3).then(sum => console.log(sum));

Output:

13
Part Meaning
async Marks a function as asynchronous. It always returns a promise, and only inside it can you use await.
await Pauses the async function until the expression’s promise settles, then unwraps its resolved value (or throws its rejection reason).
return value Becomes the fulfillment value of the promise the async function returns.
throw / rejected await Becomes the rejection reason of the promise the async function returns; catch it with try/catch or .catch() on the call site.
Arrow form const fn = async (x) => { await something(); }; works the same way.

Note that await is only legal inside a function marked async (module-level “top-level await” in ES modules is the one exception). Using it in a regular function is a syntax error.

Examples

Example 1: Basic async/await with a delayed value

function delay(ms, value) {
  return new Promise(resolve => setTimeout(() => resolve(value), ms));
}

async function greet() {
  console.log('Start');
  const message = await delay(1000, 'Hello, async world!');
  console.log(message);
  console.log('End');
}

greet();
console.log('This runs before the async function finishes');

Output:

Start
This runs before the async function finishes
Hello, async world!
End

greet() runs synchronously up to the await, logging Start. At that point it suspends and returns control to the caller, so the next top-level line runs immediately, logging the “runs before” message. Only after the 1-second timer fires does the engine resume greet, logging the message and then End. This is the core mental model: await pauses the function, not the program.

Example 2: Error handling with try/catch/finally

function fetchUserData(userId) {
  return new Promise((resolve, reject) => {
    if (userId <= 0) {
      reject(new Error('Invalid user ID'));
    } else {
      resolve({ id: userId, name: 'Ada Lovelace' });
    }
  });
}

async function loadUser(userId) {
  try {
    const user = await fetchUserData(userId);
    console.log('User loaded:', user);
  } catch (error) {
    console.log('Failed to load user:', error.message);
  } finally {
    console.log('Request finished');
  }
}

loadUser(42);
loadUser(-1);

Output:

User loaded: { id: 42, name: 'Ada Lovelace' }
Request finished
Failed to load user: Invalid user ID
Request finished

A rejected promise behind an await behaves exactly like a thrown exception: control jumps straight to the nearest catch block, and finally still runs regardless of success or failure. This is one of the biggest advantages of async/await over raw .then() chains — you get to reuse ordinary try/catch/finally instead of a separate .catch() handler.

Example 3: Sequential vs. parallel awaiting

function delay(ms, value) {
  return new Promise(resolve => setTimeout(() => resolve(value), ms));
}

async function getAllSlow(items) {
  const results = [];
  for (const item of items) {
    const result = await delay(100, item.toUpperCase());
    results.push(result);
  }
  return results;
}

async function getAllFast(items) {
  const promises = items.map(item => delay(100, item.toUpperCase()));
  return Promise.all(promises);
}

getAllSlow(['a', 'b', 'c']).then(r => console.log('Slow:', r));
getAllFast(['a', 'b', 'c']).then(r => console.log('Fast:', r));

Output:

Fast: [ 'A', 'B', 'C' ]
Slow: [ 'A', 'B', 'C' ]

getAllSlow awaits each delay one at a time inside the loop, so the three 100ms delays add up to roughly 300ms total. getAllFast starts all three delays at once with .map() and then awaits them together with Promise.all(), so the total time is roughly 100ms — the delays run concurrently. Even though getAllFast was called second, its result logs first because it finishes faster. This distinction — awaiting one-by-one versus awaiting a batch — is one of the most important performance decisions you’ll make with async code.

Under the Hood: Step by Step

Walking through Example 1 in terms of the call stack, microtask queue, and macrotask (timer) queue:

  • Step 1: greet() is called and pushed onto the call stack. It runs synchronously, logging Start.
  • Step 2: Execution reaches await delay(1000, ...). Calling delay synchronously creates a new promise and registers a setTimeout callback (a macrotask) with the browser/Node timer system.
  • Step 3: The await suspends greet, and the async function’s own promise (still pending) is handed back to whoever called greet(). The call stack for greet unwinds.
  • Step 4: The top-level console.log('This runs before...') executes, since it was next in the synchronous script.
  • Step 5: The call stack is now empty. The event loop waits until the 1000ms timer fires, then pushes the setTimeout callback onto the call stack, which calls resolve(value).
  • Step 6: Resolving the promise schedules a microtask to resume greet exactly where it left off. Because microtasks run before the next macrotask, this happens as soon as the current callback finishes.
  • Step 7: greet resumes, message is bound to the resolved value, and the remaining two console.log calls run to completion, fulfilling greet‘s own returned promise.

Every await you write triggers this same suspend-and-resume dance: synchronous run until the await, hand control back, resume later via the microtask queue once the awaited promise settles.

Common Mistakes

Mistake 1: Forgetting to await an async call

async function getData() {
  return 'data';
}

function useData() {
  const result = getData();
  console.log(result);
}

useData();

Output:

Promise { 'data' }

Because getData() returns a promise, forgetting await means result is the promise object itself, not the string inside it. This is one of the most common async bugs — code that looks correct but silently works with a wrapper object instead of the real value. The fix is to await it inside an async function:

async function getData() {
  return 'data';
}

async function useData() {
  const result = await getData();
  console.log(result);
}

useData();

Output:

data

Mistake 2: Missing await lets errors slip past try/catch

async function processOrder() {
  try {
    Promise.reject(new Error('Payment failed'));
  } catch (error) {
    console.log('Caught:', error.message);
  }
  console.log('Order processed');
}

processOrder();

Output:

Order processed

try/catch only catches errors that are thrown synchronously or that come from a rejected promise you actually await. Here, Promise.reject(...) creates a rejected promise, but nothing awaits it, so it never throws into the catch block — the catch is silently skipped, and Node will separately report an unhandled promise rejection warning outside the normal console output. Adding await fixes it:

async function processOrder() {
  try {
    await Promise.reject(new Error('Payment failed'));
  } catch (error) {
    console.log('Caught:', error.message);
  }
  console.log('Order processed');
}

processOrder();

Output:

Caught: Payment failed
Order processed

Best Practices

  • Always wrap await calls that can fail in try/catch, or attach a .catch() to the async function’s returned promise at the call site — never leave a rejection unhandled.
  • When multiple awaited operations don’t depend on each other’s results, start them together and await them with Promise.all() instead of awaiting each one sequentially in a loop.
  • Use Promise.allSettled() instead of Promise.all() when you want every operation to run to completion even if some reject, and need to inspect each outcome individually.
  • Give async functions names that reflect they return a promise-producing operation (e.g. fetchUser, loadConfig) so callers remember to await them.
  • Avoid mixing raw .then() chains with await in the same function — pick one style per function for readability.
  • Remember that an async function always returns a promise, even for synchronous-looking code; calling it without await or .then() just fires it and moves on (“fire and forget”), which is sometimes intentional but often a bug.
  • Inside array iteration methods like forEach, an async callback’s await does not pause the outer loop — use a plain for...of loop (for sequential awaits) or Promise.all(array.map(...)) (for parallel awaits) instead.

Practice Exercises

  • Write an async function getSquare(n) that returns a promise resolving to n * n after a 200ms delay (use a helper like the delay function from the examples), then call it with await and log the result.
  • Write an async function safeDivide(a, b) that rejects with an Error if b is 0, otherwise resolves with a / b. Call it inside a try/catch for both a valid and an invalid input, and log either the result or the caught error’s message.
  • Given an array of three “user id” numbers, write a function that fetches all three users in parallel (simulate the fetch with a delay-based promise) using Promise.all(), and measure how it differs from fetching them one at a time in a loop.

Summary

  • async functions always return a promise; a returned value becomes the fulfillment value, and a thrown error or rejected await becomes the rejection reason.
  • await pauses an async function until its promise settles, without blocking the rest of the program — the continuation resumes via the microtask queue.
  • await is only valid inside async functions (or at the top level of ES modules).
  • Use try/catch/finally around await to handle errors the same way you would synchronous exceptions.
  • Forgetting await is the most common bug: you get a pending or fulfilled Promise object instead of the value inside it.
  • Awaiting independent operations one-by-one in a loop is slower than starting them together and awaiting with Promise.all().
  • An unhandled rejected promise (one nobody awaits or .catch()s) will not be caught by an unrelated try/catch and will surface as an unhandled rejection warning or error.