DEV Community

Aditya Rawas
Aditya Rawas

Posted on Originally published at adityarawas.in

JavaScript Promises and Async/Await Explained: A Complete Guide

Originally published at adityarawas.in


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

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:

State Meaning
Pending The async operation is still running
Fulfilled The operation completed successfully (has a value)
Rejected The 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);
});
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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),
]);
Enter fullscreen mode Exit fullscreen mode
  • 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);
  }
});
Enter fullscreen mode Exit fullscreen mode
  • 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)),
]);
Enter fullscreen mode Exit fullscreen mode
  • 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(),
]);
Enter fullscreen mode Exit fullscreen mode
  • 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"
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

Promises vs Async/Await: When to Use Which

Situation Recommendation
Simple single operation Either — async/await is more readable
Chaining multiple operations Async/await
Parallel operations Promise.all / Promise.allSettled
Utility functions returning promises Return a Promise directly
Event handlers Async/await with try/catch
Legacy .then() chains you're refactoring Async/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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
raknaos profile image
Raknaos

"The most common async bug is sequential await where Promise.all should be used" is the line I'd have put in the title. Most of the rest of this material is known by anyone who has shipped Node for a year; that one specific shape of wrong is what actually shows up in reviews — a loop of awaits that works fine on three items and triples your latency on three hundred.

Two additions I'd make to your list. Promise.allSettled gives you the outcome but not the reason ordering: if one of five calls fails you still have to know which slot died, and the index is easy to lose once you map over results. And an unhandled rejection is not always loud — the failure I debugged longest was a promise that never settled at all, so neither the success nor the rejection path ran and the await above it just hung quietly. Did you run into that one yet, or only the classic crash-on-rejection kind?