DEV Community

Mohamed Idris
Mohamed Idris

Posted on

JavaScript Promises — Understand what a Promise actually is, how .then() chaining works under the hood, and how async/await connects to all of it

One question that comes up a lot when learning JavaScript async code is this: when you return a value inside .then(), does it get wrapped in Promise.resolve(value) automatically? And if you throw something, does it become Promise.reject(error)?

Short answer: yes, exactly right.

It's one of those things that sounds small but really clicks everything into place once you get it. Let me break it all down.


What even is a Promise?

Before ES6 (back when callbacks ruled everything), async code looked like this:

getUserFromDB(userId, function(err, user) {
  if (err) {
    handleError(err);
    return;
  }
  getPostsByUser(user.id, function(err, posts) {
    if (err) {
      handleError(err);
      return;
    }
    // imagine this going 5 levels deep...
  });
});
Enter fullscreen mode Exit fullscreen mode

This is called callback hell — and it's as bad as it sounds.

A Promise is a cleaner way to handle async operations. Think of it like ordering food at a restaurant. You place your order (start the async operation), and instead of standing at the kitchen window waiting, the waiter gives you a number (the Promise). You go sit down and do other things. When the food is ready, they call your number.

A Promise is in one of three states:

  • Pending — still waiting (the kitchen is still cooking)
  • Fulfilled — success, you have a value (food arrived)
  • Rejected — something went wrong (they're out of your order)

The .then() chaining trick

Here's the interesting part. When you chain .then() calls, each .then() returns a new Promise. And what that Promise resolves or rejects with depends on what happens inside the callback.

The rules are simple:

What you do inside .then() What the next Promise gets
return someValue Wraps it: Promise.resolve(someValue)
return anotherPromise Waits for that promise and uses its result
throw new Error(...) Wraps it: Promise.reject(error)
fetchUser(1)
  .then(user => {
    return user.name; // becomes Promise.resolve("John")
  })
  .then(name => {
    console.log(name); // "John"
    throw new Error("Oops!"); // becomes Promise.reject(Error("Oops!"))
  })
  .then(() => {
    console.log("This won't run");
  })
  .catch(err => {
    console.log(err.message); // "Oops!"
  });
Enter fullscreen mode Exit fullscreen mode

This chaining is powerful because you avoid nesting — each .then() hands off to the next one cleanly.


async/await is just Promises in disguise

async/await isn't a different system — it's syntax sugar on top of Promises. The same rules apply.

async function getUser() {
  return "John"; // same as: return Promise.resolve("John")
}

async function fail() {
  throw new Error("Something broke"); // same as: return Promise.reject(...)
}
Enter fullscreen mode Exit fullscreen mode

And await just pauses execution until the Promise settles:

async function main() {
  try {
    const user = await fetchUser(1); // waits for Promise to resolve
    console.log(user.name);
  } catch (err) {
    console.log("Error:", err.message); // catches rejections
  }
}
Enter fullscreen mode Exit fullscreen mode

The try/catch with await is equivalent to .then().catch() — same idea, cleaner look.


Real use case: Fetching data from an API

Here's a practical example — fetching a GitHub user's profile and then their repos:

// With .then() chaining
function getGithubInfo(username) {
  fetch(`https://api.github.com/users/${username}`)
    .then(response => response.json())           // parse JSON (returns a Promise)
    .then(user => {
      console.log("User:", user.name);
      return fetch(`https://api.github.com/users/${username}/repos`);
    })
    .then(response => response.json())
    .then(repos => {
      console.log("Repos:", repos.length);
    })
    .catch(err => {
      console.error("Something went wrong:", err.message);
    });
}

// Same thing with async/await — cleaner!
async function getGithubInfo(username) {
  try {
    const userResponse = await fetch(`https://api.github.com/users/${username}`);
    const user = await userResponse.json();
    console.log("User:", user.name);

    const reposResponse = await fetch(`https://api.github.com/users/${username}/repos`);
    const repos = await reposResponse.json();
    console.log("Repos:", repos.length);
  } catch (err) {
    console.error("Something went wrong:", err.message);
  }
}
Enter fullscreen mode Exit fullscreen mode

Both do the same thing. Most people prefer async/await because it reads like regular synchronous code.


Quick summary

  • A Promise represents a future value from an async operation
  • .then() always returns a new Promise — whatever you return becomes Promise.resolve(...), whatever you throw becomes Promise.reject(...)
  • async/await is built on top of Promises — they are the same thing underneath
  • Use .catch() or try/catch to handle errors

If Promises still feel fuzzy, that's okay. The best way to solidify this is to build something — try fetching data from a public API, chain a few .then() calls, and break things on purpose to see what happens.

You'll get it. It just takes repetition.


Keep asking questions — they help everyone learn.

Top comments (0)