JavaScript9 min read

JavaScript Promises & Async/Await

Master asynchronous JavaScript with Promises and async/await. Learn error handling, Promise.all, chaining, and real-world patterns for API calls and data fetching.

javascriptpromisesasync-awaitasynchronous

JavaScript Promises & Async/Await

Asynchronous code is fundamental to JavaScript. Whether you're fetching data from an API, reading a file, or waiting for user input — you need to understand how Promises and async/await work.

The Problem: Callback Hell

Before Promises, we used callbacks for async operations:

getData(function(a) {
  getMoreData(a, function(b) {
    getEvenMoreData(b, function(c) {
      console.log(c);
    });
  });
});

This nesting — called "callback hell" or the "pyramid of doom" — is hard to read, hard to debug, and hard to handle errors in.

Promises: The Solution

A Promise represents a value that will be available in the future. It has three states:

  1. Pending — still waiting
  2. Fulfilled — completed successfully
  3. Rejected — failed with an error

Creating a Promise

const promise = new Promise((resolve, reject) => {
  // Do something async
  const success = true;
  
  if (success) {
    resolve("It worked!");
  } else {
    reject(new Error("Something went wrong"));
  }
});

Using a Promise

promise
  .then(result => console.log(result))    // "It worked!"
  .catch(error => console.error(error));

Chaining Promises

Each .then() returns a new Promise, so you can chain them:

fetch("/api/user")
  .then(response => response.json())
  .then(user => fetch(`/api/posts/${user.id}`))
  .then(response => response.json())
  .then(posts => console.log(posts))
  .catch(error => console.error("Something failed:", error));

The catch at the end handles errors from ANY step in the chain. This is much cleaner than nested callbacks.

Async/Await: Promises Made Pretty

async/await is syntactic sugar over Promises. It make async code look synchronous:

async function getUserPosts() {
  const userResponse = await fetch("/api/user");
  const user = await userResponse.json();
  
  const postsResponse = await fetch(`/api/posts/${user.id}`);
  const posts = await postsResponse.json();
  
  return posts;
}

Rules

  1. await can only be used inside an async function
  2. An async function always returns a Promise
  3. await pauses execution until the Promise resolves

Error Handling with try/catch

async function getUserPosts() {
  try {
    const response = await fetch("/api/user");
    if (!response.ok) throw new Error("Failed to fetch user");
    const user = await response.json();
    return user;
  } catch (error) {
    console.error("Error:", error.message);
    return null;
  }
}

Promise Utility Methods

Promise.all — Run in Parallel

Wait for ALL promises to complete. Fails fast if any one rejects.

const [users, posts, comments] = await Promise.all([
  fetch("/api/users").then(r => r.json()),
  fetch("/api/posts").then(r => r.json()),
  fetch("/api/comments").then(r => r.json()),
]);

Use this when requests are independent and you want them to run simultaneously.

Promise.allSettled — No Fail-Fast

Wait for all promises, regardless of whether they succeed or fail:

const results = await Promise.allSettled([
  fetch("/api/critical"),
  fetch("/api/optional"),
]);

results.forEach(result => {
  if (result.status === "fulfilled") {
    console.log("Success:", result.value);
  } else {
    console.log("Failed:", result.reason);
  }
});

Promise.race — First One Wins

Returns the result of whichever promise settles first:

const result = await Promise.race([
  fetch("/api/fast-server"),
  fetch("/api/slow-server"),
]);

Useful for implementing timeouts:

function fetchWithTimeout(url, ms) {
  return Promise.race([
    fetch(url),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error("Timeout")), ms)
    ),
  ]);
}

Promise.any — First Success Wins

Like race, but ignores rejections. Only rejects if ALL promises reject:

const result = await Promise.any([
  fetch("https://cdn1.example.com/data"),
  fetch("https://cdn2.example.com/data"),
  fetch("https://cdn3.example.com/data"),
]);

Common Patterns

Sequential Execution

When order matters:

async function processItems(items) {
  const results = [];
  for (const item of items) {
    const result = await processItem(item);
    results.push(result);
  }
  return results;
}

Don't use forEach with await — it doesn't wait:

// BROKEN — these all fire at once!
items.forEach(async (item) => {
  await processItem(item);
});

Parallel Execution with Limit

Process items in batches:

async function processInBatches(items, batchSize) {
  const results = [];
  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    const batchResults = await Promise.all(
      batch.map(item => processItem(item))
    );
    results.push(...batchResults);
  }
  return results;
}

Retry Pattern

async function fetchWithRetry(url, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      const response = await fetch(url);
      if (response.ok) return response.json();
    } catch (error) {
      if (i === retries - 1) throw error;
      await new Promise(r => setTimeout(r, 1000 * (i + 1)));
    }
  }
}

Microtasks vs Macrotasks

Promise callbacks (.then) are microtasks — they execute before the next macrotask (like setTimeout):

console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");

// Output: 1, 4, 3, 2

Understanding this order helps debug timing issues.

Key Takeaways

  1. Promises represent future values — use .then()/.catch() or async/await
  2. async/await makes async code readable — prefer it over raw .then() chains
  3. Use Promise.all for parallel independent operations
  4. Always handle errors with try/catch or .catch()
  5. Never use forEach with await — use for...of instead
  6. Promise.allSettled is great when you want results regardless of individual failures

🚀 Practice What You Learned

Apply these concepts with hands-on coding challenges: