JavaScriptIntermediate

Understanding async/await in JavaScript

A practical guide to promises and async/await: how they work, how to handle errors, and how to run tasks in parallel without slowing your app down.

Timeline comparing three requests run one after another with the same requests run in parallel
On this page

JavaScript runs your code on a single thread, yet it can download files, wait for timers and talk to databases without freezing. The secret is asynchronous code, and async/await is the most readable way to write it.

In this article you'll learn what a promise actually is, how async/await builds on it, how to handle errors properly, and how to avoid the most common performance mistake developers make.

The problem: waiting without blocking#

Imagine fetching a user from an API. The network might take 300 ms. If JavaScript simply stopped and waited, your whole page would freeze: no clicks, no scrolling, no animations.

Instead, asynchronous functions return immediately with a promise, an object that stands for a value that will be available later.

Promises in 60 seconds#

A promise is always in one of three states:

  • pending: the work is still in progress
  • fulfilled: it finished successfully with a value
  • rejected: it failed with an error

You react to a promise with .then() and .catch():

JavaScript
fetch("https://api.github.com/users/octocat")
  .then((response) => response.json())
  .then((user) => console.log(user.name))
  .catch((error) => console.error("Request failed:", error));

This works, but chains get hard to follow as the logic grows, especially when one step depends on the result of another.

Enter async/await#

async/await is syntax built on top of promises. It lets you write asynchronous code that reads like synchronous code:

JavaScript
async function showUser(username) {
  const response = await fetch(`https://api.github.com/users/${username}`);
  const user = await response.json();
  console.log(user.name);
}

showUser("octocat");

Two rules make this work:

  1. async before a function makes it always return a promise.
  2. await pauses that function until the promise settles, then hands you its value. Other code keeps running in the meantime. Only this function waits.

await doesn't block the page

While showUser is paused at an await, the browser is free to handle clicks, run other functions and repaint the screen. The function resumes when the promise settles.

Handling errors with try/catch#

With .then() chains you use .catch(). With async/await you use the familiar try/catch:

JavaScript
async function getUser(username) {
  try {
    const response = await fetch(`https://api.github.com/users/${username}`);

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    console.error(`Could not load ${username}:`, error.message);
    return null;
  }
}

fetch only rejects on network failure

A 404 or 500 response does not reject the promise returned by fetch(). Always check response.ok (or response.status) yourself, as shown above.

The #1 mistake: accidentally sequential code#

Look at this function. Can you spot the problem?

JavaScript
async function loadDashboard() {
  const user = await getUser("octocat");     // ~300 ms
  const repos = await getRepos("octocat");   // ~300 ms
  const events = await getEvents("octocat"); // ~300 ms
  return { user, repos, events };            // total: ~900 ms
}

Each request waits for the previous one to finish, even though none of them depend on each other! Start them all at once and wait for them together with Promise.all():

JavaScript
async function loadDashboard() {
  const [user, repos, events] = await Promise.all([
    getUser("octocat"),
    getRepos("octocat"),
    getEvents("octocat"),
  ]);
  return { user, repos, events };            // total: ~300 ms
}

Same result, roughly three times faster.

Sequentialawait, await, await user repos events ≈ 900 ms ParallelPromise.all() user repos events ≈ 300 ms 3× faster 0300 ms600 ms900 ms
Awaiting independent requests one by one adds their times together; Promise.all runs them at the same time

Choosing the right Promise combinator#

Method Fulfils when… Rejects when…
Promise.all() all promises fulfil any promise rejects
Promise.allSettled() all promises settle never
Promise.race() the first promise to settle fulfils the first promise to settle rejects
Promise.any() the first promise fulfils all promises reject

Use Promise.allSettled() when you want every result even if some fail, for example when loading several optional widgets:

JavaScript
const results = await Promise.allSettled([getRepos("octocat"), getEvents("octocat")]);

for (const result of results) {
  if (result.status === "fulfilled") {
    render(result.value);
  } else {
    console.warn("Widget failed:", result.reason);
  }
}

Adding a timeout#

fetch() has no timeout by default. In modern browsers and Node.js 18+, AbortSignal.timeout() gives you one in a single line:

JavaScript
async function fetchWithTimeout(url, ms = 5000) {
  const response = await fetch(url, { signal: AbortSignal.timeout(ms) });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}

If the request takes longer than ms milliseconds, the promise rejects with a TimeoutError, which you can handle in a try/catch like any other error.

Loops and await#

Using await inside a for...of loop processes items one at a time. That's exactly what you want when order matters or when you must avoid hammering an API:

JavaScript
for (const id of orderIds) {
  await processOrder(id); // waits for each one before starting the next
}

When the tasks are independent, map them to promises and wait for them together instead:

JavaScript
await Promise.all(orderIds.map((id) => processOrder(id)));

forEach doesn't wait

orderIds.forEach(async (id) => await processOrder(id)) does not wait for anything, because forEach ignores the promises your callback returns. Use for...of or Promise.all() instead.

Recap#

  • A promise represents a future value: pending, fulfilled or rejected.
  • async functions always return a promise; await pauses only that function.
  • Use try/catch for errors, and remember to check response.ok with fetch().
  • Run independent tasks in parallel with Promise.all() instead of awaiting them one by one.
  • Never rely on forEach to wait for async callbacks.

Next time you write an await, ask yourself: does this line really need to wait for the one above it? That one question will make your apps noticeably faster.