Skip to main content

Promises in JavaScript: A Better Way to Handle Asynchronous Code

Updated 10 min readMarkdown
Promises in JavaScript: A Better Way to Handle Asynchronous Code

If you have worked with callbacks for any length of time, you have felt the friction. Functions nested inside functions, error handling repeated at every level, logic that reads bottom-up instead of top-down. Callbacks work, but they fight you when the async logic gets complex.

Promises were designed to fix that friction. They give asynchronous operations a cleaner interface — one that separates "start this operation" from "handle the result," and chains multiple async steps in a way that reads naturally.


What Problem Promises Solve

Callbacks have a fundamental shape problem. When async operations depend on each other, callbacks nest:

getUser(id, function(err, user) {
  if (err) return handleError(err);

  getOrders(user.id, function(err, orders) {
    if (err) return handleError(err);

    getOrderDetails(orders[0].id, function(err, details) {
      if (err) return handleError(err);

      console.log("Details:", details);
    });
  });
});

Each step lives inside the previous one. The deeper the dependency chain, the further right the code drifts. Error handling is duplicated at every level. Variables from outer steps are trapped in their own scope and difficult to share.

This pattern is called callback hell or the pyramid of doom — and the shape of the code itself is the problem.

Promises flatten this structure:

getUser(id)
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetails(orders[0].id))
  .then(details => console.log("Details:", details))
  .catch(err => handleError(err));

Same logic. Same operations. But now the steps read top to bottom, errors are handled once at the end, and each step is a clean, separate line. The structure of the code reflects the sequence of operations.


What a Promise Is

A Promise is an object that represents the eventual result of an asynchronous operation. You can think of it as a placeholder — a commitment that a value will be available at some point in the future, even though it is not available right now.

The analogy is a restaurant receipt. When you place an order, you get a ticket. The food is not ready yet, but you hold something that represents the food. When it is ready, you exchange the ticket for the meal. If the kitchen runs out of ingredients, you are notified of the failure. The ticket is the Promise — it stands in for a future outcome.

const promise = new Promise((resolve, reject) => {
  // asynchronous work happens here
  // call resolve(value) on success
  // call reject(reason) on failure
});

A Promise is created with a function called the executor. The executor receives two functions: resolve and reject. Call resolve when the operation succeeds, passing the result. Call reject when it fails, passing the reason or error.


Promise States

Every Promise exists in exactly one of three states:

Pending — the initial state. The operation has started but has not yet completed. The Promise has no value yet.

Fulfilled — the operation completed successfully. The Promise has a resolved value. This is sometimes called "resolved."

Rejected — the operation failed. The Promise holds a rejection reason, typically an Error object.

         resolve(value)
Pending ─────────────────► Fulfilled
         reject(reason)
Pending ─────────────────► Rejected

Once a Promise moves from pending to either fulfilled or rejected, it is settled. A settled Promise never changes state. A fulfilled Promise stays fulfilled. A rejected Promise stays rejected. The transition is one-way and permanent.

const willFulfill = new Promise((resolve) => {
  setTimeout(() => resolve("success!"), 1000);
});

const willReject = new Promise((_, reject) => {
  setTimeout(() => reject(new Error("something failed")), 1000);
});

Both start in the pending state. After 1 second, each moves to its settled state — one fulfilled, one rejected.


The Basic Promise Lifecycle

Creating a Promise

function fetchUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id > 0) {
        resolve({ id, name: "Priya", role: "admin" });
      } else {
        reject(new Error("Invalid user ID"));
      }
    }, 500);
  });
}

fetchUser returns a Promise immediately. The executor runs the async work (simulated here with setTimeout). On success, resolve is called with the user object. On failure, reject is called with an error.

Consuming the result

You access the resolved value with .then() and the rejection reason with .catch():

fetchUser(1)
  .then(user => {
    console.log("Got user:", user.name); // Got user: Priya
  })
  .catch(err => {
    console.log("Error:", err.message);
  });

The .then() callback runs if the Promise fulfills. The .catch() callback runs if it rejects. Neither runs synchronously — both are deferred until the Promise settles.

Full lifecycle in one example

console.log("1: Start");

const p = new Promise((resolve) => {
  console.log("2: Executor runs synchronously");
  setTimeout(() => {
    console.log("4: Resolving after 1 second");
    resolve("the value");
  }, 1000);
});

p.then(value => {
  console.log("5: Fulfilled with:", value);
});

console.log("3: After .then() registration");

Output:

1: Start
2: Executor runs synchronously
3: After .then() registration
4: Resolving after 1 second
5: Fulfilled with: the value

The executor runs immediately when the Promise is created — line 2 appears before line 3. But the .then() callback is deferred — it only runs after the Promise resolves (line 5 appears last). This is the key timing: creation is synchronous, settlement callbacks are asynchronous.


Handling Success and Failure

.then() — handling fulfillment

.then() accepts up to two arguments: a fulfillment handler and an optional rejection handler. In practice, most code uses separate .then() and .catch() calls for clarity.

fetchUser(1).then(
  user => console.log("Success:", user.name), // fulfillment handler
  err  => console.log("Error:", err.message)  // rejection handler (optional)
);

.catch() — handling rejection

.catch(fn) is equivalent to .then(null, fn). It catches rejections from the Promise it is called on, and also from any previous .then() in the chain:

fetchUser(-1)
  .then(user => processUser(user))
  .catch(err => {
    console.log("Caught:", err.message); // Caught: Invalid user ID
  });

If fetchUser(-1) rejects, .then() is skipped entirely and .catch() receives the rejection reason.

.finally() — always runs

.finally() runs regardless of whether the Promise fulfilled or rejected. It receives no arguments and is used for cleanup:

showSpinner();

fetchUser(1)
  .then(user => renderUser(user))
  .catch(err => showError(err.message))
  .finally(() => hideSpinner()); // always runs

hideSpinner runs whether the fetch succeeded or failed. Without .finally(), you would need to call it in both .then() and .catch().

Callback vs Promise — error handling comparison

// Callbacks — error check at every level
getUser(id, (err, user) => {
  if (err) { handleError(err); return; }
  getOrders(user.id, (err, orders) => {
    if (err) { handleError(err); return; }
    getDetails(orders[0].id, (err, details) => {
      if (err) { handleError(err); return; }
      render(details);
    });
  });
});

// Promises — one catch handles all
getUser(id)
  .then(user    => getOrders(user.id))
  .then(orders  => getDetails(orders[0].id))
  .then(details => render(details))
  .catch(err    => handleError(err));

In the callback version, the error check pattern if (err) { handle; return; } repeats three times. In the Promise version, one .catch() at the end handles any rejection from any step in the chain.


Promise Chaining

This is where Promises show their real value over callbacks. When .then() returns a value, it is wrapped in a new fulfilled Promise. When it returns a Promise, the chain waits for that Promise to settle before continuing. This is what makes chaining work.

fetchUser(1)
  .then(user => {
    return fetchOrders(user.id); // returns a Promise
  })
  .then(orders => {
    return fetchDetails(orders[0].id); // returns a Promise
  })
  .then(details => {
    console.log("Final result:", details);
  })
  .catch(err => {
    console.log("Any step failed:", err.message);
  });

Each .then() receives the resolved value of the previous step. If any step rejects, the chain skips all remaining .then() calls and goes directly to .catch().

Returning plain values in a chain

You can also return plain values (not Promises) from .then(). They are wrapped in a resolved Promise automatically:

fetchUser(1)
  .then(user => user.id)        // returns a number — wrapped in Promise.resolve(1)
  .then(id => `User #${id}`)    // returns a string — wrapped automatically
  .then(label => console.log(label)); // "User #1"

This lets you transform values between async steps without starting new async operations.

A real-world chain

function loadProfile(userId) {
  return fetchUser(userId)
    .then(user => {
      return fetchPosts(user.id).then(posts => ({ user, posts }));
    })
    .then(({ user, posts }) => {
      return fetchComments(posts[0].id).then(comments => ({
        user,
        posts,
        comments
      }));
    })
    .then(data => {
      renderProfile(data);
      return data;
    })
    .catch(err => {
      showError("Could not load profile.");
      console.error(err);
      return null;
    });
}

Each step depends on the previous. Variables from earlier steps stay accessible inside the nested .then() calls. One .catch() at the end handles any failure in the entire sequence.


Utility Methods

Promise.all() — run in parallel, wait for all

Promise.all([
  fetchUser(1),
  fetchPosts(),
  fetchNotifications()
]).then(([user, posts, notifications]) => {
  render({ user, posts, notifications });
}).catch(err => {
  console.log("One of the requests failed:", err.message);
});

All three requests start simultaneously. The .then() runs when all three resolve. If any one rejects, the .catch() runs immediately with that rejection reason.

Promise.allSettled() — run in parallel, handle each outcome

Promise.allSettled([
  fetchUser(1),
  fetchPosts(),
  fetchNotifications()
]).then(results => {
  results.forEach(result => {
    if (result.status === "fulfilled") {
      console.log("Value:", result.value);
    } else {
      console.log("Error:", result.reason.message);
    }
  });
});

Unlike Promise.all, Promise.allSettled waits for every Promise to settle — fulfilled or rejected — and gives you the outcome of each. Nothing is skipped.

Promise.race() — first settled wins

Promise.race([
  fetchFromPrimaryServer(),
  fetchFromBackupServer()
]).then(result => {
  console.log("Fastest response:", result);
});

Resolves or rejects with the outcome of whichever Promise settles first. Useful for timeouts and fallback strategies.

Promise.resolve() and Promise.reject()

These create already-settled Promises:

Promise.resolve(42).then(val => console.log(val)); // 42
Promise.reject(new Error("fail")).catch(err => console.log(err.message)); // fail

Useful for returning consistent Promise-based values from functions that may or may not do async work.


Common Mistakes

Forgetting to return in a chain:

// Wrong — the chain does not wait for fetchOrders
fetchUser(1)
  .then(user => {
    fetchOrders(user.id); // missing return
  })
  .then(orders => {
    console.log(orders); // undefined — previous step returned nothing
  });

// Correct
fetchUser(1)
  .then(user => fetchOrders(user.id)) // return the Promise
  .then(orders => console.log(orders));

Without return, .then() resolves with undefined immediately instead of waiting for the inner Promise.

Creating a new Promise when one already exists:

// Unnecessary wrapper
function getUser(id) {
  return new Promise((resolve, reject) => {
    fetch(`/api/users/${id}`)
      .then(r => r.json())
      .then(data => resolve(data))
      .catch(err => reject(err));
  });
}

// Simpler — fetch already returns a Promise
function getUser(id) {
  return fetch(`/api/users/${id}`).then(r => r.json());
}

Wrapping a Promise in another Promise is called the explicit Promise construction antipattern. Since fetch already returns a Promise, just return the chain directly.


Wrapping Up

Promises are the foundation of modern asynchronous JavaScript. They solve the structural problems of callbacks — the nesting, the scattered error handling, the scope barriers between steps — by giving you a linear, chainable interface for async operations.

The three states — pending, fulfilled, rejected — map directly to the lifecycle of any async operation. .then() handles success. .catch() handles failure. .finally() handles cleanup. Chaining connects multiple async steps so they read in the order they execute.

async/await — which you may already know — is built entirely on Promises. Every async function returns a Promise. Every await expression waits for one. Understanding how Promises work makes async/await entirely predictable rather than partially magical.

No comments yet. Be the first to comment.

More from this blog