If you've been writing JavaScript for more than a few weeks, you've probably run into code that looks something like this:
getUser(userId, (err, user) => {
if (err) return handleError(err);
getPosts(user.id, (err, posts) => {
if (err) return handleError(err);
getComments(posts[0].id, (err, comments) => {
if (err) return handleError(err);
console.log(comments);
});
});
});
This is the infamous "callback hell" — and it's exactly the kind of code async/await was designed to replace.
In this guide, I'll walk through what async/await actually does, how to use it properly, and the pitfalls I kept running into when I was learning it. No theory dumps — just working code you can run.
What You'll Learn
- What
asyncandawaitactually do under the hood - How they compare to promises and callbacks
- Five real examples you'll use in actual projects
- The three mistakes that trip up almost every beginner
Prerequisites: you should know what a Promise is (roughly — "an object that represents a value that will exist later"). If you don't, read the first two paragraphs of MDN's Promise page and come back.
The Short Version
async/await is syntactic sugar over Promises. It doesn't add new capabilities to JavaScript — it just lets you write asynchronous code that looks synchronous.
Here's the callback mess from above, rewritten with async/await:
async function showComments(userId) {
try {
const user = await getUser(userId);
const posts = await getPosts(user.id);
const comments = await getComments(posts[0].id);
console.log(comments);
} catch (err) {
handleError(err);
}
}
Same logic, half the lines, readable top-to-bottom. That's the whole pitch.
How It Actually Works
Two keywords, two simple rules:
async — when you put this in front of a function, that function automatically returns a Promise. Even if you return 5, the caller receives Promise<5>.
await — you can only use this inside an async function. It pauses execution until the Promise on its right resolves, then gives you the resolved value.
async function getGreeting() {
return "hello"; // This is actually Promise<"hello">
}
async function main() {
const greeting = await getGreeting(); // Unwraps the promise
console.log(greeting); // "hello"
}
main();
That's genuinely most of what you need to know. The rest is practice.
Example 1: Fetching Data From an API
This is where most people first encounter async/await. Here's a clean fetch call:
async function getUser(id) {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`);
}
const user = await response.json();
return user;
}
// Using it:
async function main() {
try {
const user = await getUser(42);
console.log(user.name);
} catch (err) {
console.error("Something went wrong:", err.message);
}
}
main();
Notice await appears twice — once for the fetch itself (which returns a Promise of a Response), and once for .json() (which also returns a Promise). Both are promises, both need awaiting.
Example 2: Running Things in Parallel
Here's a mistake I made for months. Compare these two versions:
// Slow version — runs sequentially
async function getAllDataSlow() {
const users = await fetchUsers(); // waits 1 second
const posts = await fetchPosts(); // then waits another second
const comments = await fetchComments(); // then waits another second
return { users, posts, comments }; // total: ~3 seconds
}
// Fast version — runs in parallel
async function getAllDataFast() {
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments()
]);
return { users, posts, comments }; // total: ~1 second
}
Rule of thumb: if the results don't depend on each other, use Promise.all. Using await on consecutive lines forces your code to wait unnecessarily.
Example 3: Handling Errors Properly
try/catch works with await exactly like it works with regular synchronous code, which is one of the best things about it:
async function saveUser(userData) {
try {
const validated = await validateUser(userData);
const saved = await db.users.insert(validated);
await sendWelcomeEmail(saved.email);
return saved;
} catch (err) {
// This catches errors from ANY of the three awaits above
console.error("Failed to save user:", err);
throw err; // rethrow if the caller should handle it
}
}
One catch block catches errors from any await inside the try. You don't need .catch() chains anymore.
Example 4: Awaiting in a Loop
This one trips up everyone. Say you want to process an array of items, calling an async function on each:
// This does NOT work the way you'd expect
async function processItems(items) {
items.forEach(async (item) => {
await processOne(item); // runs all at once, in no guaranteed order
});
console.log("Done!"); // this logs BEFORE any item finishes
}
forEach doesn't know about promises. It fires all the async callbacks without waiting. Use a regular for...of loop instead:
// Sequential — one at a time, in order
async function processItems(items) {
for (const item of items) {
await processOne(item);
}
console.log("Done!"); // now this logs after everything finishes
}
Or if order doesn't matter and you want speed:
// Parallel — all at once, wait for all to finish
async function processItems(items) {
await Promise.all(items.map(item => processOne(item)));
console.log("Done!");
}
Example 5: Mixing async/await with Regular Promises
You don't have to choose. These two do the same thing:
// Using await
async function getUserName(id) {
const user = await getUser(id);
return user.name;
}
// Using .then()
function getUserName(id) {
return getUser(id).then(user => user.name);
}
Both return a Promise, both work fine. Use whichever is clearer for the situation. .then() is often nicer for a single transformation; await wins once you have two or more steps.
Three Mistakes I Kept Making
1. Forgetting await
async function main() {
const user = getUser(1); // ❌ forgot await
console.log(user.name); // undefined — user is a Promise, not a user
}
If something is "undefined" when it shouldn't be, check for missing awaits first.
2. Using await outside an async function
function main() {
const user = await getUser(1); // ❌ SyntaxError
}
await only works inside async functions. (Modern environments support top-level await in ES modules, but inside a regular function, you need async.)
3. Making every function async when it doesn't need to be
async function add(a, b) {
return a + b; // ❌ no async needed — nothing is awaited
}
This works, but it wraps the result in a Promise for no reason. Only use async when you're actually awaiting something.
When to Use async/await vs. Promises
Honestly? Almost always use async/await now. It's cleaner, easier to debug (stack traces are more helpful), and the try/catch error handling is much more readable than .catch() chains.
Stick with .then() only when:
- You're doing a single transformation and don't want the overhead of a function wrapper
- You need
Promisecombinators likePromise.all,Promise.race,Promise.any(though these work fine withawaittoo)
Wrapping Up
async/await looks intimidating, but once you understand the two rules — async functions return Promises, await unwraps them — most of the syntax falls into place.
The best way to learn it is to rewrite some of your callback or .then()-based code using async/await. You'll almost always end up with cleaner code, and the exercise makes the mental model stick.
A couple of things I didn't cover that are worth exploring next:
-
Top-level
awaitin ES modules -
AsyncIterators and
for await...offor streaming data - AbortController for cancelling in-flight async operations
If you found this useful, I'm writing more pieces like this on JavaScript fundamentals. The best way to learn is to try the examples — copy any of the snippets above into your browser's console and play with them.
Thanks for reading. If you spotted an error or have a follow-up question, I'd love to hear it in the comments.
Top comments (0)