Skip to content Skip to sidebar Skip to footer

JavaScript Promises – A Deep Dive

Hey there! 👋 In this blog, let’s go beyond the surface and deeply understand what JavaScript Promises are, why they exist, how they work under the hood, and how you can use them effectively in your projects. By the end, you won’t just “know” Promises—you’ll think in Promises.

JavaScript is single-threaded. This means long-running tasks (like fetching data from an API, reading files, or waiting for timers) can block the main thread and freeze the UI if done synchronously.

Earlier, we solved this with callbacks, but callbacks lead to messy code known as “callback hell”:

doSomething((result1) => {
  doSomethingElse(result1, (result2) => {
    doThirdThing(result2, (result3) => {
      console.log("All done!", result3);
    });
  });
});

Promises were introduced to fix this—they give a cleaner way to manage asynchronous operations and avoid deeply nested callbacks.

A Promise is an object representing the eventual completion (or failure) of an asynchronous operation. You can think of it as a placeholder for a value that will be available later.

In JavaScript, a Promise has three states:

  1. Pending – initial state, operation not completed yet.
  2. Fulfilled (Resolved) – operation completed successfully.
  3. Rejected – operation failed with an error.

A Promise is created using the Promise constructor, which takes an executor function with two callbacks: resolve and reject.

const myPromise = new Promise((resolve, reject) => {
  const isDataAvailable = true;

  if (isDataAvailable) {
    resolve("Data fetched successfully!");
  } else {
    reject("Failed to fetch data.");
  }
});

To consume the promise:

myPromise
  .then(result => console.log(result))     // runs if resolved
  .catch(error => console.error(error))    // runs if rejected
  .finally(() => console.log("Done!"));    // always runs

One of the biggest advantages of Promises is chaining—you can sequence async tasks without nesting.

fetchUser()
  .then(user => fetchOrders(user.id))
  .then(orders => fetchOrderDetails(orders[0].id))
  .then(details => console.log(details))
  .catch(err => console.error(err));

Each .then() returns a new promise, allowing the chain to stay clean and predictable.

Errors bubble down the chain until caught:

doSomething()
  .then(result => doSomethingElse(result))
  .then(final => console.log(final))
  .catch(err => console.error("Caught an error:", err));

If any promise rejects, the catch() will handle it. You can also use finally() for cleanup tasks (like hiding a loader).

Runs multiple promises in parallel and waits for all to fulfill. If any fail, the whole thing rejects.

Promise.all([
  Promise.resolve("First"),
  Promise.resolve("Second"),
  Promise.resolve("Third")
])
  .then(values => console.log(values)) // ["First", "Second", "Third"]
  .catch(err => console.error(err));

Resolves/rejects as soon as any one promise settles.

Promise.race([
  new Promise(res => setTimeout(res, 500, "Slow")),
  new Promise(res => setTimeout(res, 100, "Fast"))
])
  .then(value => console.log(value)); // "Fast"

Returns the first fulfilled promise (ignores rejections). If all fail, it rejects with an AggregateError.

Promise.any([
  Promise.reject("Fail 1"),
  Promise.resolve("Success!"),
  Promise.reject("Fail 2")
])
  .then(value => console.log(value)); // "Success!"

Waits for all promises to finish, no matter whether they fulfill or reject.

Promise.allSettled([
  Promise.resolve("Yay"),
  Promise.reject("Oops")
])
  .then(results => console.log(results));
/*
[
  { status: "fulfilled", value: "Yay" },
  { status: "rejected", reason: "Oops" }
]
*/

Promises use the microtask queue, which has higher priority than the macrotask queue (setTimeout, setInterval).

console.log("Start");

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

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

console.log("End");

// Output:
// Start
// End
// Microtask
// Macrotask

This is why .then() callbacks often run before setTimeout even with zero delay.

Instead of chaining .then(), you can write asynchronous code that looks synchronous:

async function fetchData() {
  try {
    const user = await fetchUser();
    const orders = await fetchOrders(user.id);
    console.log(orders);
  } catch (err) {
    console.error(err);
  }
}

Under the hood, async/await is just Promises with cleaner syntax.

  1. Always handle errors with .catch() or try/catch with async/await.
  2. Avoid mixing callbacks and promises unnecessarily.
  3. Use Promise.all() for parallel work instead of waiting sequentially.
  4. Understand microtasks vs macrotasks to avoid tricky bugs.
  5. Use finally() for cleanup like hiding loaders.

TL;DR:

  • Promises represent future values of async operations.
  • They have pending → fulfilled/rejected states.
  • Use .then(), .catch(), .finally() for control flow.
  • Use Promise.all(), race(), any(), allSettled() for managing multiple promises.
  • Async/await makes promise code look synchronous.

Leave a comment