close

DEV Community

Sotheara
Sotheara

Posted on • Originally published at sotheara-blog.hashnode.dev

Understanding async/await in JavaScript: A Beginner's Guide With Real Examples

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);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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 async and await actually 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!");
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 Promise combinators like Promise.all, Promise.race, Promise.any (though these work fine with await too)

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 await in ES modules
  • AsyncIterators and for await...of for 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)