Skip to main content

Command Palette

Search for a command to run...

JavaScript Promises: Taming Asynchronous Code Like a Pro

Updated
7 min readView as Markdown

The Problem: Asynchronous JavaScript Is Messy

JavaScript is single-threaded. But the real world isn't. You fetch data from APIs, read files, wait for timers — and JavaScript has to handle all of that without freezing the browser.

The classic solution was callbacks. You pass a function, and it gets called when the async work is done. Simple in theory. Brutal in practice.

Here's what fetching a user, then their posts, then the comments on their first post looks like with callbacks:

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

  getPosts(user.id, function(err, posts) {
    if (err) return handleError(err);

    getComments(posts[0].id, function(err, comments) {
      if (err) return handleError(err);

      // Finally... do something with comments
      console.log(comments);
    });
  });
});

This staircase of doom is what developers call Callback Hell (or the Pyramid of Doom). It's hard to read, painful to debug, and almost impossible to maintain.

Promises were born to fix this.

What Is a Promise? (Think: A Future Value)

Imagine you order a pizza. The restaurant doesn't hand you the pizza immediately — they give you a receipt. That receipt is a promise of a future pizza. You can go sit down, do other things, and the pizza will arrive — or the restaurant calls to say they're out of ingredients.

A JavaScript Promise works exactly the same way. It's an object that represents a value that isn't available yet, but will be at some point in the future.

const pizzaPromise = orderPizza(); // Returns immediately — with a promise, not a pizza

You now hold a reference to a future result. You can pass it around, chain operations on it, or wait for it to resolve.

Promise States: The Three Phases of a Promise's Life

Every Promise lives in one of three states:

┌─────────────────────────────────────────────────────────┐
│                                                         │
│   ⏳ PENDING  ──► ✅ FULFILLED  (resolve was called)   │
│                                                         │
│   ⏳ PENDING  ──► ❌ REJECTED   (reject was called)    │
│                                                         │
│   Once settled (fulfilled or rejected), a promise       │
│   NEVER changes state again. It is immutable.           │
│                                                         │
└─────────────────────────────────────────────────────────┘
State Meaning Can transition to
Pending The async operation is still in progress Fulfilled or Rejected
Fulfilled The operation completed successfully — (final)
Rejected The operation failed — (final)

This is crucial: a settled Promise is permanent. Once it resolves or rejects, it stays that way forever. No surprises, no race conditions.

Creating a Promise: The Basic Lifecycle

You create a Promise using the Promise constructor, which takes a single function called the executor. The executor receives two arguments: resolve and reject.

const myPromise = new Promise((resolve, reject) => {
  // Do your async work here...

  const success = true; // Imagine this is the result of an API call

  if (success) {
    resolve("🎉 Here is your data!"); // Moves to FULFILLED
  } else {
    reject(new Error("💥 Something went wrong")); // Moves to REJECTED
  }
});

The executor runs immediately and synchronously. But resolve and reject schedule the handlers to run later, asynchronously.

Here's a real-world-style example — simulating a network request with setTimeout:

function fetchUserData(userId) {
  return new Promise((resolve, reject) => {
    console.log("Fetching user..."); // Runs immediately

    setTimeout(() => {
      if (userId > 0) {
        resolve({ id: userId, name: "Arjun Sharma" }); // 1 second later
      } else {
        reject(new Error("Invalid user ID")); // Or this
      }
    }, 1000);
  });
}

Handling Success and Failure: .then() and .catch()

Once you have a Promise, you handle its outcome using two methods:

  • .then(onFulfilled) — runs when the promise is fulfilled

  • .catch(onRejected) — runs when the promise is rejected

  • .finally(callback) — runs regardless of outcome (cleanup)

fetchUserData(42)
  .then((user) => {
    console.log("Got user:", user.name); // ✅ "Got user: Arjun Sharma"
  })
  .catch((error) => {
    console.error("Failed:", error.message); // ❌ runs if rejected
  })
  .finally(() => {
    console.log("Done! (always runs)"); // 🏁 cleanup, hide loaders, etc.
  });

Compare this to the callback version. Same logic — but now it reads top-to-bottom like a story.

Callbacks vs Promises — Side by Side

// ❌ Callback approach
getUser(id, (err, user) => {
  if (err) { handleError(err); return; }
  getPosts(user.id, (err, posts) => {
    if (err) { handleError(err); return; }
    console.log(posts);
  });
});

// ✅ Promise approach
getUser(id)
  .then(user => getPosts(user.id))
  .then(posts => console.log(posts))
  .catch(handleError); // One handler catches ALL errors

The difference in readability is night and day. Notice also that with Promises, a single .catch() handles errors from anywhere in the chain. With callbacks, you're manually checking err at every single level.

Promise Chaining: The Real Power Move

This is where Promises truly shine. When you return a value from a .then() callback, it gets wrapped in a new Promise, which the next .then() can consume

fetchUserData(42)
  .then((user) => {
    console.log("Step 1 - User:", user.name);
    return fetchPosts(user.id); // Return another promise!
  })
  .then((posts) => {
    console.log("Step 2 - Posts:", posts.length);
    return fetchComments(posts[0].id); // And another!
  })
  .then((comments) => {
    console.log("Step 3 - Comments:", comments);
  })
  .catch((error) => {
    // Catches errors from ANY step above
    console.error("Something failed:", error.message);
  });

This is the flat, readable version of callback hell. Every step flows naturally to the next. If any step throws an error or rejects, execution jumps straight to .catch() — no manual error checking needed.

Transforming Data in a Chain

.then() can also transform data, not just kick off new async operations:

fetch("https://api.example.com/users/1")
  .then(response => response.json())         // Parse JSON
  .then(user => user.name.toUpperCase())     // Transform data
  .then(name => console.log(name))           // Use it
  .catch(err => console.error(err));

Each .then() receives the return value of the previous one. Clean. Composable. Readable.

Quick Reference: Promise Cheat Sheet

// ✅ Creating a promise
const p = new Promise((resolve, reject) => { ... });

// ✅ Consuming a promise
p.then(value => { ... })          // on success
 .catch(error => { ... })         // on failure
 .finally(() => { ... });         // always

// ✅ Immediately resolved/rejected
Promise.resolve("value");
Promise.reject(new Error("oops"));

// ✅ Wait for multiple promises
Promise.all([p1, p2, p3])         // all must succeed
Promise.allSettled([p1, p2, p3])  // wait for all, regardless
Promise.race([p1, p2, p3])        // first one wins
Promise.any([p1, p2, p3])         // first success wins// ✅ Creating a promise
const p = new Promise((resolve, reject) => { ... });

// ✅ Consuming a promise
p.then(value => { ... })          // on success
 .catch(error => { ... })         // on failure
 .finally(() => { ... });         // always

// ✅ Immediately resolved/rejected
Promise.resolve("value");
Promise.reject(new Error("oops"));

// ✅ Wait for multiple promises
Promise.all([p1, p2, p3])         // all must succeed
Promise.allSettled([p1, p2, p3])  // wait for all, regardless
Promise.race([p1, p2, p3])        // first one wins
Promise.any([p1, p2, p3])         // first success wins

What's Next?

Promises are the foundation — but modern JavaScript goes further. Once you're comfortable with the concepts here, the next step is async/await, which is syntactic sugar built directly on top of Promises. It makes asynchronous code look even more like synchronous code, using the same underlying mechanics you just learned.

// The same chain from above — with async/await
async function loadData() {
  try {
    const user = await fetchUserData(42);
    const posts = await fetchPosts(user.id);
    const comments = await fetchComments(posts[0].id);
    console.log(comments);
  } catch (error) {
    console.error("Failed:", error.message);
  }
}

Wrapping Up

Here's what you learned today:

  • Promises represent a future value — like a receipt for async work

  • Three states: Pending → Fulfilled or Rejected (immutable once settled)

  • .then() handles success, .catch() handles failure, .finally() always runs

  • Chaining flattens callback hell into clean, readable, sequential code

  • One .catch() at the end handles errors from your entire chain

Promises don't eliminate async complexity — they organize it. And organized code is code you can actually reason about, maintain, and ship with confidence.

More from this blog