JavaScript September 15, 2026 Aditya Rawas 5 min read

JavaScript Promises and Async/Await Explained: A Complete Guide

Asynchronous programming is one of the most important — and most misunderstood — parts of JavaScript. Whether you’re fetching data from an API, reading files in Node.js, or querying a database, you’re doing async work. Promises and async/await are the modern tools for handling it.

This guide takes you from the original callback problem all the way through Promise internals, chaining, error handling, parallel execution, and async/await — with practical patterns you can use immediately.


The Problem: Callback Hell

Before Promises, async operations were handled with callbacks — functions passed as arguments that get called when the operation completes.

getUserById(1, function(err, user) {
  if (err) return handleError(err);
  getPostsByUser(user.id, function(err, posts) {
    if (err) return handleError(err);
    getCommentsForPost(posts[0].id, function(err, comments) {
      if (err) return handleError(err);
      console.log(comments);
    });
  });
});

This pattern — called callback hell or the pyramid of doom — has real problems:

  • Error handling must be repeated at every level
  • Logic is deeply nested and hard to read
  • Sequential async steps become tangled
  • Adding retry logic or timeouts is painful

Promises were introduced in ES6 (2015) to solve this.


What is a Promise?

A Promise is an object representing the eventual completion or failure of an asynchronous operation. It acts as a placeholder for a value that doesn’t exist yet.

A Promise has three possible states:

StateMeaning
PendingThe async operation is still running
FulfilledThe operation completed successfully (has a value)
RejectedThe operation failed (has a reason/error)

Once a Promise is fulfilled or rejected, it is settled — it never changes state again.

Creating a Promise

const promise = new Promise((resolve, reject) => {
  // Simulate an async operation
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve("Data loaded"); // fulfills the promise
    } else {
      reject(new Error("Network failure")); // rejects the promise
    }
  }, 1000);
});

The Promise constructor takes an executor function that receives two callbacks:

  • resolve(value) — call this when the operation succeeds
  • reject(reason) — call this when it fails

Consuming Promises with .then() and .catch()

promise
  .then((value) => {
    console.log(value); // "Data loaded"
  })
  .catch((error) => {
    console.error(error.message); // "Network failure"
  })
  .finally(() => {
    console.log("Done — runs regardless of outcome");
  });
  • .then(onFulfilled) — runs when the promise resolves
  • .catch(onRejected) — runs when the promise rejects
  • .finally(callback) — runs in both cases (cleanup, hiding spinners, etc.)

Promise Chaining

The real power of Promises comes from chaining — each .then() returns a new Promise, so you can chain operations that depend on each other without nesting.

fetchUser(1)
  .then((user) => fetchPosts(user.id))     // returns a new Promise
  .then((posts) => fetchComments(posts[0].id))
  .then((comments) => console.log(comments))
  .catch((error) => console.error(error)); // catches any error in the chain

Compare this to the callback version above — the logic is flat, readable, and has a single error handler.

Returning Values in .then()

Whatever you return from a .then() callback becomes the resolved value of the next Promise in the chain:

Promise.resolve(5)
  .then((n) => n * 2)   // returns 10
  .then((n) => n + 3)   // returns 13
  .then((n) => console.log(n)); // 13

If you return a Promise, the chain waits for it to settle before continuing.


Error Handling

A .catch() at the end of a chain catches any rejection from any step above it:

fetchUser(1)
  .then((user) => {
    if (!user.active) throw new Error("User is deactivated");
    return fetchPosts(user.id);
  })
  .then((posts) => processPosts(posts))
  .catch((error) => {
    // Catches: network errors, thrown errors, or rejected promises
    console.error("Something went wrong:", error.message);
  });

You can also place .catch() in the middle of a chain to recover from errors and continue:

fetchUser(1)
  .catch(() => getGuestUser())  // fallback if fetchUser fails
  .then((user) => renderProfile(user));

Always Handle Rejections

Unhandled promise rejections crash Node.js processes (since Node 15+) and log warnings in browsers. Always add a .catch() or use try/catch with async/await.


Running Promises in Parallel

When you have multiple independent async operations, run them in parallel instead of sequentially.

Promise.all — All Must Succeed

const [user, posts, settings] = await Promise.all([
  fetchUser(1),
  fetchPosts(1),
  fetchSettings(1),
]);
  • Runs all three in parallel — total time ≈ slowest single request
  • If any promise rejects, the whole Promise.all rejects immediately
  • Use when you need all results and any failure should stop everything

Promise.allSettled — Collect All Results

const results = await Promise.allSettled([
  fetchUser(1),
  fetchPosts(1),
  fetchSettings(1),
]);

results.forEach((result) => {
  if (result.status === "fulfilled") {
    console.log(result.value);
  } else {
    console.error(result.reason);
  }
});
  • Waits for all promises to settle regardless of success or failure
  • Use when you want all results and partial failures are acceptable

Promise.race — First One Wins

const result = await Promise.race([
  fetchData(),
  new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), 5000)),
]);
  • Resolves or rejects as soon as the first promise settles
  • Useful for implementing timeouts

Promise.any — First Success Wins

const result = await Promise.any([
  fetchFromServer1(),
  fetchFromServer2(),
  fetchFromServer3(),
]);
  • Resolves with the first fulfilled promise
  • Only rejects if all promises reject (throws AggregateError)
  • Useful for trying multiple sources and taking whichever responds first

Async/Await

Async/await is syntax sugar over Promises — it doesn’t replace them, it makes them look like synchronous code.

The async keyword

Adding async before a function makes it always return a Promise:

async function greet() {
  return "Hello"; // automatically wrapped in Promise.resolve("Hello")
}

greet().then(console.log); // "Hello"

The await keyword

await pauses execution inside an async function until the Promise settles:

async function loadUserData() {
  const user = await fetchUser(1);       // waits for this to resolve
  const posts = await fetchPosts(user.id); // then waits for this
  return { user, posts };
}

This is equivalent to the .then() chain, but reads like synchronous code.

Error Handling with try/catch

async function loadUserData() {
  try {
    const user = await fetchUser(1);
    const posts = await fetchPosts(user.id);
    return { user, posts };
  } catch (error) {
    console.error("Failed to load data:", error.message);
    return null;
  }
}

Common Async/Await Mistakes

Mistake 1: Forgetting await

// Wrong — `user` is a Promise, not the resolved value
async function getUser() {
  const user = fetchUser(1); // missing await
  console.log(user.name);   // undefined
}

// Correct
async function getUser() {
  const user = await fetchUser(1);
  console.log(user.name);
}

Mistake 2: Sequential await When Parallel is Possible

// Slow — runs one after the other (sum of both delays)
async function slow() {
  const user = await fetchUser(1);
  const posts = await fetchPosts(1);
}

// Fast — runs in parallel (max of both delays)
async function fast() {
  const [user, posts] = await Promise.all([fetchUser(1), fetchPosts(1)]);
}

This is the single most common performance bug in async code. If two operations don’t depend on each other, always parallelize with Promise.all.

Mistake 3: await Inside .forEach()

// Wrong — forEach doesn't wait for async callbacks
async function processAll(ids) {
  ids.forEach(async (id) => {
    await processItem(id); // these all fire simultaneously, forEach returns immediately
  });
}

// Correct — sequential with for...of
async function processAll(ids) {
  for (const id of ids) {
    await processItem(id);
  }
}

// Correct — parallel with Promise.all
async function processAll(ids) {
  await Promise.all(ids.map((id) => processItem(id)));
}

Mistake 4: Unhandled Rejections

// Dangerous — if fetchData() rejects, the error is silently swallowed
async function load() {
  const data = await fetchData(); // no try/catch
}

// Safe
async function load() {
  try {
    const data = await fetchData();
  } catch (err) {
    handleError(err);
  }
}

Real-World Example: Fetching API Data

Here’s a complete pattern for fetching data with proper error handling and loading states:

async function fetchUserProfile(userId) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout

  try {
    const response = await fetch(`/api/users/${userId}`, {
      signal: controller.signal,
    });

    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }

    const user = await response.json();
    return user;
  } catch (error) {
    if (error.name === "AbortError") {
      throw new Error("Request timed out");
    }
    throw error;
  } finally {
    clearTimeout(timeout);
  }
}

Promises vs Async/Await: When to Use Which

SituationRecommendation
Simple single operationEither — async/await is more readable
Chaining multiple operationsAsync/await
Parallel operationsPromise.all / Promise.allSettled
Utility functions returning promisesReturn a Promise directly
Event handlersAsync/await with try/catch
Legacy .then() chains you’re refactoringAsync/await

In practice, most modern codebases use async/await for control flow and Promise.all for parallelism, with .catch() reserved for one-liners.


How Promises Fit in the Event Loop

Understanding where Promises sit in the event loop explains some subtle timing behaviors:

  • Promise callbacks (.then(), .catch()) are scheduled as microtasks
  • Microtasks run before the next macrotask (setTimeout, setInterval, I/O)
console.log("1");

setTimeout(() => console.log("2"), 0);

Promise.resolve().then(() => console.log("3"));

console.log("4");

// Output: 1, 4, 3, 2

Even though setTimeout has a delay of 0ms, the Promise microtask (3) runs before it (2). This is why deeply nested Promise chains can occasionally starve the macrotask queue.

For a deeper look at the event loop phases — timers, I/O, and libuv — see Is Node.js Single-Threaded or Multi-Threaded?.


Key Takeaways

  • A Promise is an object that represents a value available now, in the future, or never — in one of three states: pending, fulfilled, or rejected.
  • Promise chaining with .then() flattens nested async logic into readable sequential steps.
  • Promise.all runs operations in parallel and fails fast; Promise.allSettled collects all results regardless of failure.
  • async/await is syntax sugar over Promises — it makes async code look synchronous without blocking the thread.
  • The most common async bug is sequential await where Promise.all should be used.
  • Promise callbacks run as microtasks — before setTimeout and other macrotasks.
  • Always handle rejections with .catch() or try/catch to avoid unhandled rejection crashes.

Never Miss an Article

Stay Updated

Get new deep-dives on JavaScript, TypeScript, Go, and cloud-native engineering delivered to your reader.

Aditya Rawas

Written by

Aditya Rawas

Full-stack engineer writing deep-dives on JavaScript, TypeScript, React, AWS, Docker, and Kubernetes. Passionate about making complex engineering concepts accessible to developers at every level.