Start.dev
Back to blog

JavaScript async/await explained, without the confusion

A plain-English guide to JavaScript async/await: what it really does, how it relates to promises, and the mistakes that trip people up.

Ariel Weinberger
Ariel Weinberger ยท 5 min read

You've probably written this line before: const data = await fetch(url). It works, so you move on. Then one day it doesn't. The function returns a promise instead of a value, a loop takes ten times longer than it should, or an error vanishes without a trace.

Most async/await confusion has one cause. People learn the syntax without learning what it's built on. This post fixes that. By the end you'll know what async and await actually do, when to run things in parallel, and how to handle errors properly.

First, the problem async/await solves

JavaScript runs your code on a single thread. If it waited for a network request to finish before doing anything else, your page would freeze. So slow operations (network calls, timers, file reads) are started and then handled later, when they finish.

Early JavaScript did this with callbacks, which nested into the infamous "callback hell." Promises flattened that. async/await goes one step further by letting promise-based code read like ordinary top-to-bottom code.

The key idea: async/await is not a new mechanism. It's nicer syntax for promises.

A promise in one minute

A promise is an object that stands for a value you don't have yet. It's in one of three states:

  • pending: still working
  • fulfilled: finished, and has a value
  • rejected: failed, and has an error

With plain promises you chain handlers:

fetch("/api/user")
  .then((response) => response.json())
  .then((user) => console.log(user.name))
  .catch((error) => console.error(error));

This works, but the logic reads sideways. Here's the same thing with async/await:

async function showUser() {
  try {
    const response = await fetch("/api/user");
    const user = await response.json();
    console.log(user.name);
  } catch (error) {
    console.error(error);
  }
}

Same behavior, but it reads top to bottom.

What async does

Putting async before a function does exactly one thing to its output: it makes the function always return a promise.

async function getNumber() {
  return 42;
}
 
console.log(getNumber()); // Promise { 42 }, not 42

If you return a plain value, JavaScript wraps it in a fulfilled promise. If you throw, the promise is rejected. This is the number one source of confusion: you can't get a plain value out of an async function without waiting for it.

What await does

await can only be used inside an async function (or at the top level of an ES module). It does two things:

  1. Pauses that function until the promise settles.
  2. Unwraps the result: you get the fulfilled value, or an exception is thrown if it rejected.

The important detail is what it doesn't pause. Only the async function waits. The rest of your program keeps running.

async function main() {
  console.log("1. start");
  const value = await Promise.resolve("2. resolved");
  console.log(value);
}
 
main();
console.log("3. this runs before the await finishes");
 
// Output:
// 1. start
// 3. this runs before the await finishes
// 2. resolved

When main() hits await, it hands control back to the caller. That's why line 3 prints before line 2. If you've ever been surprised by output order, this is why.

Mistake #1: awaiting things one at a time that could run together

This is the most common performance bug. Each await waits for the previous line to finish:

// Slow: takes about 2 seconds if each call takes 1 second
const users = await getUsers();
const posts = await getPosts();

These two requests don't depend on each other, so there's no reason to run them in sequence. Start both, then wait for both:

// Fast: takes about 1 second
const [users, posts] = await Promise.all([getUsers(), getPosts()]);

Promise.all takes an array of promises and gives you back one promise that resolves when all of them do. If any one rejects, Promise.all rejects right away.

Use sequential await when step two needs the result of step one. Use Promise.all when the work is independent.

Other combinators worth knowing

MethodResolves whenUse it for
Promise.allall succeed (rejects on first failure)independent tasks that must all work
Promise.allSettledall finish, success or failure"try everything, report each result"
Promise.racethe first one settlestimeouts
Promise.anythe first one succeedsfastest-mirror patterns

Mistake #2: await inside forEach

This one looks right and silently misbehaves:

async function saveAll(items) {
  items.forEach(async (item) => {
    await save(item);
  });
  console.log("done"); // prints BEFORE the saves finish
}

forEach ignores the promises its callback returns, so it never waits. Use a for...of loop when order matters:

for (const item of items) {
  await save(item); // one at a time, in order
}

Or Promise.all with map when order doesn't matter and you want speed:

await Promise.all(items.map((item) => save(item)));

Mistake #3: forgetting to await

Forget the await and you get a promise where you expected a value:

async function greet() {
  const response = await fetch("/api/user");
  const user = response.json(); // missing await
  console.log(user.name); // undefined: user is a Promise
}

Nothing throws. You just get undefined somewhere downstream. If a value looks like Promise { <pending> } in your logs, you're missing an await.

Handling errors

Because await throws when a promise rejects, you can use ordinary try/catch:

async function loadProfile(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      throw new Error(`Request failed: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    console.error("Could not load profile:", error);
    throw error; // let the caller decide what to do too
  }
}

Two things worth noticing:

  • fetch does not reject on HTTP errors like 404 or 500. It only rejects on network failure. Check response.ok yourself.
  • If you don't catch an error inside an async function, it becomes a rejected promise for the caller to handle. If nobody handles it, you get an "unhandled rejection." Always have a plan for where errors end up.

A quick mental model

Keep these four rules in your head and most confusion goes away:

  1. An async function always returns a promise.
  2. await pauses only the function it's in, not the whole program.
  3. await unwraps a promise into its value, or throws if it failed.
  4. Sequential awaits run one after another. Use Promise.all to run work together.

Practice it

Reading about this only gets you so far. Async code clicks when you write it and see the order of execution for yourself. Our lesson on Async Await (opens in a new tab) walks through it step by step, and it's part of the Intermediate JavaScript (opens in a new tab) course if you want the surrounding concepts too.

Try this exercise on your own: write a function that fetches three different URLs, first sequentially and then with Promise.all, and time both with console.time. Seeing the difference in milliseconds is more convincing than any explanation.

Wrap-up

async/await isn't magic. It's promises with friendlier syntax. Once you remember that async returns a promise, await pauses only its own function, and independent work belongs in Promise.all, you can read and write async code with confidence.

These courses give you the foundation and the next step:

Level up your coding skills

Jump into interactive courses and start building real-world projects.

Start learning

Related posts

What useEffect really does, when it runs, and how the dependency array and cleanup function work. Simple examples for fetching data, timers and page titles.
Ariel Weinberger
Ariel Weinberger ยท 5 min read
Learn how REST APIs work by following one request from client to server and back: URLs, methods, headers, status codes, JSON, and what REST actually means.
Ariel Weinberger
Ariel Weinberger ยท 7 min read
Learn the JavaScript array methods you will use daily: map, filter, find, some, every, reduce and sort, with runnable examples on one shopping cart.
Ariel Weinberger
Ariel Weinberger ยท 5 min read