loading bytebasex.com

JavaScript Promises and Async/Await Explained

Learn how JavaScript Promises and async/await work, avoid common silent bugs, and write faster parallel async code.

You fetch some data, then need to fetch more data based on the result, then update the page once that's done. Written the naive way, with callbacks calling callbacks calling callbacks, this turns into a rightward-marching staircase of nested functions that's genuinely hard to read, let alone debug. This exact problem, commonly nicknamed "callback hell," is what Promises were built to solve.

JavaScript Promises and Async/Await Explained

Welcome to BytebaseX. I'm Suptojit Modak, and in this guide, I'll break down what a Promise actually represents, how async/await relates to it under the hood, the specific mistakes that cause "silent" bugs in async code, and when you actually want things running in parallel instead of one after another.

What a Promise Actually Is

A Promise is an object representing a value that isn't available yet, but will be at some point, either successfully (resolved) or unsuccessfully (rejected). Every Promise exists in exactly one of three states:

  • Pending: the operation hasn't finished yet. This is the starting state for every Promise.
  • Fulfilled: the operation completed successfully, and the Promise now holds a resulting value.
  • Rejected: the operation failed, and the Promise now holds a reason (usually an error).
A Promise is not the value itself, it's a container that will eventually hold the value. This is the mental shift that makes everything else click: you're not working with data directly, you're working with a placeholder that resolves later.

Creating and Consuming a Promise

Most of the time, you won't write raw Promises yourself, you'll consume ones returned by browser APIs like fetch(), or by libraries. But seeing how one is built makes the whole concept far less abstract:

function waitThenGreet(name) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (name) {
        resolve(`Hello, ${name}!`);
      } else {
        reject(new Error("No name provided"));
      }
    }, 1000);
  });
}

Inside the Promise, you call resolve(value) when the operation succeeds, or reject(error) when it fails. Consuming it looks like this:

waitThenGreet("Suptojit")
  .then(message => console.log(message))
  .catch(error => console.error(error));

.then() runs when the Promise resolves successfully. .catch() runs when it rejects. Only one of the two will actually fire for any given Promise, never both.

A Live Demo: Watching States Change

Reading about pending, fulfilled, and rejected is one thing. Click the buttons below to actually watch a simulated Promise move through these states.

State: idle

Click a button to start a simulated 1.5 second async operation.

Chaining: Avoiding the Nested Staircase

The real value of Promises shows up when one async step depends on the result of another. Each .then() returns a new Promise, which is what makes chaining possible instead of nesting:

fetchUser(userId)
  .then(user => fetchOrders(user.id))
  .then(orders => fetchOrderDetails(orders[0].id))
  .then(details => console.log(details))
  .catch(error => console.error("Something failed:", error));

Notice there's only one .catch() at the end, not one after every step. A rejection at any point in the chain skips straight past every remaining .then() and jumps directly to the nearest .catch(). This is genuinely one of the more elegant parts of how Promises work: you get centralized error handling without wrapping every single step in its own try/catch.

async/await: The Same Thing, Easier to Read

Promises solved callback hell, but chains of .then() calls can still get visually noisy once you have several steps. async/await, introduced later, is syntactic sugar over Promises, meaning it compiles down to the exact same underlying mechanism, just written to look like ordinary, top-to-bottom synchronous code:

async function getOrderDetails(userId) {
  try {
    const user = await fetchUser(userId);
    const orders = await fetchOrders(user.id);
    const details = await fetchOrderDetails(orders[0].id);
    console.log(details);
  } catch (error) {
    console.error("Something failed:", error);
  }
}

This does exactly the same thing as the chained version above. The await keyword pauses execution of this function (and only this function, not the entire program) until the Promise it's waiting on settles, then continues with the resolved value. If that Promise rejects, execution jumps straight to the catch block, the same way a rejected chain skips to .catch().

Info: await can only be used inside a function marked async (with one modern exception: top-level await inside ES modules). Trying to use it in a regular function throws a syntax error, since the JavaScript engine needs to know upfront that this function might pause partway through.

Sequential vs Parallel: The Mistake That Silently Wastes Time

Here's a genuinely common performance mistake that doesn't throw any error, it just quietly makes your code slower than it needs to be. If two async operations don't actually depend on each other, awaiting them one after another still works correctly, but wastes time:

// Slow: waits for the first to fully finish before starting the second
const user = await fetchUser(userId);
const settings = await fetchSettings(userId);

If fetchSettings doesn't need anything from fetchUser's result, there's no reason to wait for one before starting the other. Kick them off together instead, using Promise.all():

// Fast: both requests start at the same time
const [user, settings] = await Promise.all([
  fetchUser(userId),
  fetchSettings(userId)
]);

If each request takes around 500 milliseconds, the sequential version takes roughly 1 second total, while the parallel version takes roughly 500 milliseconds, since both requests are in flight at the same time. This gap only grows the more independent operations you're running, and it's one of the easiest, highest-impact optimizations in everyday async code.

Warning: Promise.all() rejects immediately if any of the Promises passed to it reject, even if the others would have succeeded. If you need every result regardless of individual failures, use Promise.allSettled() instead, covered below.

Promise.all, Promise.race, and Promise.allSettled

Beyond running Promises one at a time, JavaScript provides a few built-in combinators for common multi-Promise patterns:

MethodBehavior
Promise.all()Waits for every Promise to resolve. Rejects immediately if any one of them rejects.
Promise.allSettled()Waits for every Promise to finish, regardless of success or failure, and gives you the outcome of each one individually.
Promise.race()Resolves or rejects as soon as the first Promise in the group settles, ignoring the rest.
Promise.any()Resolves as soon as the first Promise succeeds. Only rejects if every single one fails.

Promise.allSettled() is particularly useful when you're firing off several independent requests and want to know the outcome of each, without one failure derailing your ability to see the results of the others:

const results = await Promise.allSettled([
  fetchUser(userId),
  fetchSettings(userId),
  fetchNotifications(userId)
]);

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

Common Mistakes With async/await

Forgetting to await a Promise

This is the single most common async bug, and it rarely throws an obvious error. Forget the await keyword, and you get the Promise object itself, still pending, instead of the value you actually wanted:

async function getUser() {
  const user = fetchUser(123); // missing await
  console.log(user.name); // undefined, because "user" is a Promise, not the actual data
}

The fix is simply remembering the await, but the reason this is worth flagging specifically is that it often doesn't crash your program; it just silently produces undefined or unexpected behavior several steps downstream, which makes it genuinely annoying to trace back to the actual cause.

Wrapping await in .then() Unnecessarily

Mixing the two styles works, but it defeats the readability benefit async/await was introduced for:

// Don't mix styles like this
async function getUser() {
  const user = await fetchUser(123).then(u => u);
}

// Just do this
async function getUser() {
  const user = await fetchUser(123);
}

Using await Inside a Loop When Parallel Would Work

This is the loop version of the sequential-vs-parallel issue covered earlier:

// Slow: each request waits for the previous one to finish
for (const id of userIds) {
  const user = await fetchUser(id);
  results.push(user);
}

// Fast: all requests start together
const results = await Promise.all(
  userIds.map(id => fetchUser(id))
);

The loop version is correct, it works, but it processes users one at a time even though there's no actual dependency between them. Swapping to Promise.all() with .map() starts every request simultaneously instead.

Quick Reference

What you wantSyntax
Handle a resolved valuepromise.then(value => ...)
Handle a rejectionpromise.catch(error => ...)
Write async code that reads top-to-bottomasync function() { await ... }
Handle errors in async/awaittry { await ... } catch (e) { ... }
Run several Promises in parallel, need all to succeedPromise.all([...])
Run several Promises in parallel, want every outcome regardlessPromise.allSettled([...])
Only care about whichever finishes firstPromise.race([...])

Frequently Asked Questions

Is async/await replacing Promises entirely?

No, async/await is built directly on top of Promises, not a replacement for them. Every async function still returns a Promise, and await still works by waiting on a Promise underneath. Understanding Promises is what makes async/await make sense, rather than feeling like a syntax you memorized without knowing why it works.

What does an async function return if I don't explicitly return anything?

It still returns a Promise, one that resolves to undefined. Every async function automatically wraps its return value in a Promise, even if you write a plain return statement with a regular value, or no return statement at all.

Can I use await outside of an async function?

Generally no, it throws a syntax error in a regular function. The one exception is top-level await inside an ES module (a file loaded with type="module"), which was added specifically to let module-level code wait on an async operation before the rest of the module runs.

Why does my try/catch not catch an error from a Promise I forgot to await?

Without await, your code moves on immediately instead of pausing for the Promise to settle, so the try/catch block around it has already finished executing by the time the Promise actually rejects. The rejection happens later, disconnected from that try/catch entirely, which is another reason a missing await tends to produce confusing, hard-to-trace bugs.

What's the difference between Promise.race and Promise.any?

Promise.race settles as soon as the first Promise settles, whether that's a success or a failure. Promise.any specifically waits for the first success, and only rejects if every single Promise in the group fails. Use race when you genuinely don't care whether the fastest result was a success or failure; use any when you specifically want the first successful result and don't mind ignoring earlier failures.

Conclusion

Promises represent a value that isn't ready yet, in one of three states, and async/await is simply a more readable way to work with them. Learn .then()/.catch() chaining first, since it teaches you what's actually happening underneath, then lean on async/await for daily use, since it reads like ordinary code. The one habit worth building early: whenever you have two or more independent async operations, stop and ask whether they should run in parallel with Promise.all() instead of one after another.

One common real-world pairing worth knowing: wrapping an async call like this inside a debounced function, for something like search-as-you-type, introduces its own specific race condition. That combination, and how to guard against it, is covered in Debouncing and Throttling in JavaScript Explained.

About the author

Suptojit Modak
I'm Suptojit Modak, a web developer and the person behind BytebaseX — a blog with tutorials, guides, and free resources for developers and bloggers, built to be simple and easy to follow.

Instagram · GitHub · Facebook

Post a Comment