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.

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 42If 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:
- Pauses that function until the promise settles.
- 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. resolvedWhen 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
| Method | Resolves when | Use it for |
|---|---|---|
Promise.all | all succeed (rejects on first failure) | independent tasks that must all work |
Promise.allSettled | all finish, success or failure | "try everything, report each result" |
Promise.race | the first one settles | timeouts |
Promise.any | the first one succeeds | fastest-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:
fetchdoes not reject on HTTP errors like 404 or 500. It only rejects on network failure. Checkresponse.okyourself.- 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:
- An
asyncfunction always returns a promise. awaitpauses only the function it's in, not the whole program.awaitunwraps a promise into its value, or throws if it failed.- Sequential
awaits run one after another. UsePromise.allto 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:








