JavaScript async / await: Promises, Syntax, and Common Pitfalls

JavaScript async / await is a cleaner way to write asynchronous code that still uses Promises under the hood. It helps you read network requests, timers, file-like operations in Node.js, and other delayed work in a style that looks more like normal synchronous code.

Quick answer: Mark a function with async to make it return a Promise, then use await inside it to pause execution until another Promise settles. Use try...catch to handle failures.

Difficulty: Beginner to Intermediate

You'll understand this better if you know: basic JavaScript functions, what a Promise is, and how values are assigned to variables.

1. What Is async / await?

async / await is syntax for working with Promises in a more direct style. It does not replace Promises; it makes Promise-based code easier to read and write.

This pattern is especially useful when one async operation must finish before the next one starts.

2. Why async / await Matters

Before async / await, Promise chains often became long and nested. That made code harder to follow, especially when you needed multiple steps in sequence.

async / await matters because it:

It is not always the fastest way to do everything, but it is often the clearest way to express intent.

3. Basic Syntax or Core Idea

The minimal pattern is an async function that uses await on a Promise.

Function declaration

Here is the core shape of the syntax:

async function loadData() {
  const result = await fetchData();
  return result;
}

In this pattern, async makes the function return a Promise automatically. The await expression waits for fetchData() to resolve, then stores the resolved value in result.

What each part means

4. Step-by-Step Examples

Example 1: Waiting for a Promise to resolve

Start with a Promise that resolves after a delay. Then wait for it inside an async function.

function delay(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

async function run() {
  console.log("Start");
  await delay(1000);
  console.log("One second later");
}

run();

This example shows that await pauses only the run function, not the whole program.

Example 2: Reading a network response with fetch

The browser fetch() API returns a Promise, so it is a natural fit for await.

async function loadProfile() {
  const response = await fetch("/api/profile");
  const profile = await response.json();

  return profile;
}

Here, the first await waits for the HTTP response, and the second waits for the JSON body to be parsed.

Example 3: Handling errors with try...catch

When a Promise rejects, await throws an error. Use try...catch to handle that failure.

async function loadUser() {
  try {
    const response = await fetch("/api/user");

    if (!response.ok) {
      throw new Error("Request failed");
    }

    return await response.json();
  } catch (error) {
    console.error("Could not load user:", error);
    return null;
  }
}

This pattern is useful because it keeps success and failure handling in one place.

Example 4: Sequential versus parallel awaits

Sometimes you want one operation after another. Other times, you want multiple independent Promises to run together.

async function loadDashboard() {
  // Sequential: each step waits for the previous one.
  const user = await fetchUser();
  const settings = await fetchSettings(user.id);

  // Parallel: both promises start before waiting for either one.
  const [posts, notifications] = await Promise.all([
    fetchPosts(),
    fetchNotifications()
  ]);

  return { user, settings, posts, notifications };
}

This shows an important skill: await is great for sequencing, but independent work is often better with Promise.all.

5. Practical Use Cases

Use async / await when your code needs to wait for asynchronous work that already returns a Promise.

If the code does not need to wait for anything, async / await is unnecessary overhead.

6. Common Mistakes

Mistake 1: Using await outside an async function

Many beginners try to use await in a regular function or at the top level of a classic script.

Problem: In ordinary scripts and non-async functions, JavaScript reports a syntax error such as await is only valid in async functions and the top level bodies of modules.

function loadData() {
  const response = await fetch("/api/data");
  return response;
}

Fix: Mark the function as async, or move the code into a module if you need top-level await.

async function loadData() {
  const response = await fetch("/api/data");
  return response;
}

The corrected version works because await is now inside an async function.

Mistake 2: Forgetting that async functions return Promises

An async function does not return a plain value immediately. It always returns a Promise.

Problem: Code that expects a direct value can fail or produce undefined behavior because the function result is still a Promise object.

async function getName() {
  return "Ada";
}

const name = getName();
console.log(name.toUpperCase());

Fix: Wait for the Promise with await, or use then() if you are outside an async function.

async function showName() {
  const name = await getName();
  console.log(name.toUpperCase());
}

The fixed version works because name now holds the resolved string instead of a Promise.

Mistake 3: Not handling rejected Promises

When a Promise rejects and nobody catches it, the failure becomes an unhandled rejection.

Problem: This often shows up as Uncaught (in promise) in the console, which means the error escaped without a handler.

async function loadData() {
  const response = await fetch("/api/missing");
  return await response.json();
}

loadData();

Fix: Catch errors inside the async function, or handle the returned Promise where you call it.

async function loadData() {
  try {
    const response = await fetch("/api/missing");
    return await response.json();
  } catch (error) {
    console.error(error);
    return null;
  }
}

loadData().catch((error) => {
  console.error("Unexpected failure:", error);
});

The corrected version works because every rejection has a clear place to go.

7. Best Practices

Practice 1: Use async / await for readable sequencing

When one async step depends on the previous result, await makes the dependency obvious.

async function loadOrder(id) {
  const order = await fetchOrder(id);
  const shipping = await fetchShipping(order.shippingId);
  return { order, shipping };
}

This reads like a step-by-step recipe, which is easier to maintain than nested callbacks or deeply chained Promises.

Practice 2: Use Promise.all for independent operations

If two requests do not depend on each other, start them together and wait once.

async function loadPageData() {
  const [user, notifications] = await Promise.all([
    fetchUser(),
    fetchNotifications()
  ]);

  return { user, notifications };
}

This is usually faster than awaiting each Promise one by one, because both operations can run in parallel.

Practice 3: Keep error handling close to the await

Handle expected failures near the code that can fail. This makes the behavior easier to reason about.

async function saveProfile(profile) {
  try {
    const response = await fetch("/api/profile", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(profile)
    });

    if (!response.ok) {
      throw new Error("Save failed");
    }

    return true;
  } catch (error) {
    console.error("Could not save profile", error);
    return false;
  }
}

This pattern keeps failure handling local and prevents silent rejection bugs.

8. Limitations and Edge Cases

Note: If you need ordered async processing over a list, use for...of with await, or collect Promises and combine them with Promise.all.

9. Practical Mini Project

Let’s build a tiny browser example that loads a message and shows success or failure in the page.

<button id="loadButton">Load message</button>
<p id="status">Idle</p>

<script>
  const button = document.getElementById("loadButton");
  const status = document.getElementById("status");

  function delay(ms) {
    return new Promise((resolve) => {
      setTimeout(resolve, ms);
    });
  }

  async function loadMessage() {
    try {
      status.textContent = "Loading...";
      await delay(1000);
      status.textContent = "Message loaded successfully.";
    } catch (error) {
      status.textContent = "Something went wrong.";
      console.error(error);
    }
  }

  button.addEventListener("click", loadMessage);
</script>

This mini project combines a Promise, an async function, and a user action. It shows the full pattern you will use often in real browser code.

10. Key Points

11. Practice Exercise

Write an async function that:

Expected output: an object or message showing both resolved values when successful, or an error message when one request fails.

Hint: Use Promise.all for the two independent operations, then wrap the code in try...catch.

function getUser() {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ id: 1, name: "Ada" }), 300);
  });
}

function getSettings() {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ theme: "dark" }), 400);
  });
}

async function loadUserData() {
  try {
    const [user, settings] = await Promise.all([
      getUser(),
      getSettings()
    ]);

    return { user, settings };
  } catch (error) {
    console.error("Failed to load data:", error);
    return null;
  }
}

loadUserData().then((data) => {
  console.log(data);
});

This solution works because both Promises start together, and the catch path prevents an unhandled rejection.

12. Final Summary

async / await is a Promise-friendly way to write asynchronous JavaScript that is easier to read than many nested Promise chains. It is best for code that needs to wait for values in sequence, especially when one step depends on the result of the previous step.

Remember that async does not remove asynchrony, and await does not make multiple tasks run in parallel by itself. Use Promise.all for independent work, and use try...catch to keep errors under control.

If you want to go further, the next useful topic is Promises themselves, especially Promise.all, rejection handling, and how async functions behave when they return values or throw errors.