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.

Browser support: Widely available since Oct 2019 (and more below)

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.

  • async goes before a function declaration or function expression.
  • await can be used only inside an async function in standard JavaScript code.
  • await pauses the function until the Promise settles, then gives you the resolved value.
  • If the Promise rejects, await throws an error that you can catch with try...catch.

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:

  • reduces callback-style nesting in Promise-heavy code,
  • makes sequential async logic easier to read,
  • fits naturally with try...catch for error handling,
  • works with almost every Promise-based API in browsers and Node.js.

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

3. Basic Syntax

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

Function declaration

Here is the core shape of the syntax:

JavaScript
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

  • async function tells JavaScript that the function may pause and resume.
  • await can be placed before any expression that returns a Promise.
  • return inside an async function wraps the value in a resolved Promise.

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.

JavaScript
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();
Output✓ Tested · Node 24
Start
One second later

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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

  • Loading API data in a browser before rendering a page section.
  • Submitting a form and waiting for a server response.
  • Reading files in Node.js with Promise-based APIs.
  • Running multi-step workflows where each step depends on the previous one.
  • Combining several independent requests with Promise.all inside an async function.

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.

JavaScript
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.

JavaScript
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.

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

const name = getName();
console.log(name.toUpperCase());
Output✓ Tested · Node 24
Uncaught TypeError: name.toUpperCase is not a function

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

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
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

  • await does not make code magically parallel; if you write several awaits in a row, they still run one after another.
  • await only works with thenable values and Promises; awaiting a plain value returns it immediately.
  • An async function always returns a Promise, even if you return a simple value.
  • In older scripts, top-level await is not allowed unless the code runs as a module.
  • Using await inside Array.prototype.forEach does not wait the way many beginners expect, because forEach does not handle Promises.

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.

HTML
<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

  • async functions always return Promises.
  • await pauses only the current async function, not the entire JavaScript runtime.
  • Use try...catch to handle rejected Promises cleanly.
  • Use Promise.all when tasks can run at the same time.
  • Top-level await depends on module support.

11. Practice Exercise

Write an async function that:

  • waits for two independent Promises,
  • returns both results together,
  • handles errors without crashing,
  • prints a friendly message if either Promise fails.

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.

Show solution
JavaScript
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);
});
Output✓ Tested · Node 24
{ user: { id: 1, name: "Ada" }, settings: { theme: "dark" } }

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

Browser Support

FeatureChromeEdgeFirefoxSafariSafari iOS
Async functions
Widely available since Oct 2019
55+15+52+10.1+10.3+
Top-level await
Newly available since Sep 2026
89+89+89+27+27+

Minimum versions with full support. Data from web-features 3.43.0 (the data behind MDN's Baseline), updated at every site build.

Check Your Understanding

Pick an answer for each question, then check your score.

1. What does an async function always return?
Show answer

Answer: A Promise. Even return 5 inside an async function gives the caller a Promise that resolves to 5.

2. What does await pause?
Show answer

Answer: Only the current async function. Other code, events, and timers keep running while that one function waits.

3. How do you handle a rejected promise inside an async function?
Show answer

Answer: Wrap the await in try...catch. A rejected promise makes await throw, so try...catch handles it like any other error.

4. Two requests don't depend on each other. How do you wait for both most efficiently?
Show answer

Answer: await Promise.all([taskA(), taskB()]). Sequential awaits make the second request wait for the first. Promise.all starts both at once and waits for both.

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.