Skip to main content

Command Palette

Search for a command to run...

JavaScript Promises Explained for Beginners

Updated
13 min read
JavaScript Promises Explained for Beginners
H
CS undergrad | Tech enthusiast | Focusing on Web Dev • DSA • ML | Building skills for real-world impact

Promises are how JavaScript handles operations that take time. Instead of freezing your code waiting for a result, a promise says "I'll get back to you later." This makes your code responsive and easier to read.

This is about understanding promises and writing better asynchronous JavaScript.


What Problem Do Promises Solve?

JavaScript runs one line at a time. But some operations take time: fetching data, reading files, database queries.

The Blocking Problem

Without promises, you'd write code that waits:

// This would freeze everything
const data = getDataFromServer();  // Takes 2 seconds
console.log(data);  // Waits 2 seconds before running
doOtherStuff();     // Waits 4 seconds total

The entire program stops. Nothing else happens. This is bad for user experience.

Real Example: Restaurant

Think of ordering food at a restaurant.

The old way (blocking):

1. Place order
2. Stand at counter
3. Stare at kitchen
4. Wait (30 minutes)
5. Finally eat

You're blocked. You can't do anything else.

The promise way (non-blocking):

1. Place order
2. Get pager number
3. Go sit down
4. Do other things
5. Pager buzzes when food is ready
6. Pick it up and eat

You're not blocked. The pager is like a promise. It tells you "I'll notify you when your order is ready."

The Callback Approach

Before promises, developers used callbacks:

getDataFromServer(function(data) {
  console.log(data);
  doOtherStuff();
});

// Code continues here (non-blocking)

This works, but leads to "callback hell":

getUser(1, function(user) {
  getPostsForUser(user.id, function(posts) {
    getCommentsForPost(posts[0].id, function(comments) {
      getAuthorForComment(comments[0].id, function(author) {
        console.log(author.name);
        // 4 levels deep - hard to read
      });
    });
  });
});

What Promises Solve

Promises make asynchronous code readable:

// Much cleaner
const user = await getUser(1);
const posts = await getPostsForUser(user.id);
const comments = await getCommentsForPost(posts[0].id);
const author = await getAuthorForComment(comments[0].id);
console.log(author.name);

Same logic. Way more readable.


Understanding Promises as Future Values

A promise is an object that represents a value that doesn't exist yet.

Simple Analogy

Imagine ordering a birthday cake from a bakery.

The bakery gives you a receipt (the promise). The receipt doesn't contain the cake yet. But it promises:

  • Eventually, you'll get a cake
  • Or the bakery will tell you they ran out (rejection)

You can:

  • Wait for the receipt to become the cake (.then())
  • Plan what to do with the cake (chain operations)
  • Handle the "ran out of cake" situation (.catch())

The receipt is like a promise. It's not the cake, but it represents the future cake.

Promise Definition

A promise is an object that represents:

  • A value that doesn't exist yet
  • But will exist in the future (or will fail)
const promise = new Promise((resolve, reject) => {
  // Operation that takes time
  setTimeout(() => {
    resolve("Done!");  // Success - provide the value
  }, 1000);
});

console.log(promise);  // Promise object (not "Done!" yet)

promise.then((result) => {
  console.log(result);  // "Done!" (after 1 second)
});

The promise is created instantly. But the result arrives later.


Promise States

A promise has three states:

State 1: Pending

Promise created
      |
      v
Waiting for operation to complete
      |
      v
State: PENDING

The promise is waiting. The operation hasn't finished yet.

const promise = new Promise((resolve, reject) => {
  // At this point, promise is PENDING
  // Operation hasn't finished
  setTimeout(() => {
    resolve("Done!");
  }, 1000);
});

// Here, promise is still PENDING
console.log(promise);  // Promise { <pending> }

State 2: Fulfilled

Operation completes successfully
      |
      v
resolve() is called
      |
      v
State: FULFILLED
Value is now available

The operation succeeded. The promise has a value.

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("Success!");  // Call resolve()
  }, 1000);
});

promise.then((value) => {
  console.log(value);  // "Success!"
});

State 3: Rejected

Operation fails
      |
      v
reject() is called
      |
      v
State: REJECTED
Error is available

The operation failed. The promise has an error.

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    reject("Something went wrong!");  // Call reject()
  }, 1000);
});

promise.catch((error) => {
  console.log(error);  // "Something went wrong!"
});

State Transitions

PENDING
  |
  ├─ Operation succeeds → resolve() → FULFILLED
  |
  └─ Operation fails → reject() → REJECTED

A promise starts pending. Then it moves to fulfilled or rejected. Never both. Never back to pending.

Key Rule: Promises are Immutable

Once a promise is fulfilled or rejected, it can't change:

const promise = new Promise((resolve, reject) => {
  resolve("First");
  resolve("Second");  // Ignored
  reject("Error");    // Ignored
});

promise.then((value) => {
  console.log(value);  // "First" only
});

Only the first resolve() or reject() matters. The rest are ignored.


Promise Lifecycle Diagram

Promise Created
      |
      v
Promise is PENDING
(Operation running in background)
      |
      ├─ Success Path        OR        Failure Path
      |                                  |
      v                                  v
   resolve("value")                   reject("error")
      |                                  |
      v                                  v
  FULFILLED                           REJECTED
  (Value ready)                       (Error ready)
      |                                  |
      v                                  v
 .then(callback)                    .catch(callback)
      |                                  |
      v                                  v
  Handler runs                      Handler runs
  (with value)                      (with error)

Creating Promises

Creating a Promise Manually

const promise = new Promise((resolve, reject) => {
  // resolve and reject are functions
  // Call resolve(value) when successful
  // Call reject(error) when failed
});

Simple Example: Timer Promise

const delayPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("Done waiting!");
  }, 2000);
});

console.log("Start");
delayPromise.then((message) => {
  console.log(message);
});
console.log("End");

// Output:
// Start
// End
// (2 seconds later)
// Done waiting!

The code doesn't wait for the promise. It continues. After 2 seconds, the handler runs.

Example: Random Success or Failure

const randomPromise = new Promise((resolve, reject) => {
  const random = Math.random();
  
  if (random > 0.5) {
    resolve("Lucky!");
  } else {
    reject("Unlucky!");
  }
});

randomPromise
  .then((message) => {
    console.log("Success:", message);
  })
  .catch((error) => {
    console.log("Error:", error);
  });

Sometimes it succeeds. Sometimes it fails.


Handling Success and Failure

The .then() Method

.then() runs when the promise is fulfilled:

promise.then((value) => {
  console.log("Promise succeeded:", value);
});

The callback receives the value from resolve().

The .catch() Method

.catch() runs when the promise is rejected:

promise.catch((error) => {
  console.log("Promise failed:", error);
});

The callback receives the error from reject().

Both Together

const promise = new Promise((resolve, reject) => {
  const success = Math.random() > 0.5;
  
  if (success) {
    resolve("It worked!");
  } else {
    reject("It failed!");
  }
});

promise
  .then((value) => {
    console.log("Success:", value);
  })
  .catch((error) => {
    console.log("Error:", error);
  });

One or the other will run. Never both.

Real Example: Fetching Data

const fetchUserData = new Promise((resolve, reject) => {
  // Simulate API call
  setTimeout(() => {
    const success = Math.random() > 0.2;
    
    if (success) {
      const user = { id: 1, name: "Alice" };
      resolve(user);
    } else {
      reject("Network error");
    }
  }, 1000);
});

fetchUserData
  .then((user) => {
    console.log("User:", user);
    console.log("Name:", user.name);
  })
  .catch((error) => {
    console.log("Failed to fetch:", error);
  });

The .finally() Method

.finally() runs regardless of success or failure:

promise
  .then((value) => {
    console.log("Success:", value);
  })
  .catch((error) => {
    console.log("Error:", error);
  })
  .finally(() => {
    console.log("Operation complete (success or failure)");
  });

Use .finally() for cleanup code. Like closing a database connection.


Promise Chaining

Promises can be chained. Each .then() returns a new promise.

Why Chaining?

Instead of nested callbacks:

// Callback hell
getUser(1, function(user) {
  getPostsForUser(user.id, function(posts) {
    console.log(posts);
  });
});

You can chain promises:

// Much cleaner
getUser(1)
  .then((user) => {
    return getPostsForUser(user.id);
  })
  .then((posts) => {
    console.log(posts);
  });

Linear. Readable. Each step is clear.

How Chaining Works

Each .then() returns a promise:

const step1 = getUser(1);          // Returns promise

const step2 = step1.then((user) => {
  return getPostsForUser(user.id);  // Returns new promise
});

const step3 = step2.then((posts) => {
  console.log(posts);               // No return = promise resolves with undefined
});

Or chained together:

getUser(1)
  .then((user) => {
    return getPostsForUser(user.id);
  })
  .then((posts) => {
    return getCommentsForPost(posts[0].id);
  })
  .then((comments) => {
    console.log(comments);
  })
  .catch((error) => {
    console.log("Something failed:", error);
  });

Passing Data Through Chains

getUser(1)
  .then((user) => {
    console.log("Step 1:", user);
    return getPostsForUser(user.id);  // Pass to next step
  })
  .then((posts) => {
    console.log("Step 2:", posts);
    return posts[0];                  // Pass post to next step
  })
  .then((firstPost) => {
    console.log("Step 3:", firstPost);
  });

Each step receives the return value from the previous step.

Error Handling in Chains

If any step fails, the chain stops and jumps to .catch():

getUser(1)
  .then((user) => {
    return getPostsForUser(user.id);  // If this fails...
  })
  .then((posts) => {
    return getCommentsForPost(posts[0].id);  // This won't run
  })
  .then((comments) => {
    console.log(comments);             // This won't run
  })
  .catch((error) => {
    console.log("Error caught here:", error);  // Error caught here
  });

One error anywhere in the chain is caught at the end.

Practical Example: Login Flow

authenticateUser(email, password)
  .then((token) => {
    console.log("Login successful");
    return fetchUserData(token);  // Use token to fetch data
  })
  .then((user) => {
    console.log("User loaded:", user.name);
    return updateLastLogin(user.id);
  })
  .then(() => {
    console.log("Last login updated");
    redirectToDashboard();
  })
  .catch((error) => {
    console.log("Login failed:", error);
    showErrorMessage(error);
  });

Clean flow: authenticate → fetch → update → redirect.


Callback vs Promise Comparison

Callback Approach

// Nested callbacks - "callback hell"
getUser(1, function(err, user) {
  if (err) {
    console.log("Error:", err);
  } else {
    getPostsForUser(user.id, function(err, posts) {
      if (err) {
        console.log("Error:", err);
      } else {
        getCommentsForPost(posts[0].id, function(err, comments) {
          if (err) {
            console.log("Error:", err);
          } else {
            console.log("Comments:", comments);
          }
        });
      }
    });
  }
});

Problems:

  • Hard to read (pyramid of doom)
  • Error handling scattered everywhere
  • Difficult to follow the flow
  • Easy to make mistakes

Promise Approach

// Clear chain - easy to follow
getUser(1)
  .then((user) => {
    return getPostsForUser(user.id);
  })
  .then((posts) => {
    return getCommentsForPost(posts[0].id);
  })
  .then((comments) => {
    console.log("Comments:", comments);
  })
  .catch((error) => {
    console.log("Error:", error);
  });

Benefits:

  • Linear flow (top to bottom)
  • Error handling in one place
  • Easy to read
  • Easy to modify

Async/Await (Built on Promises)

Modern JavaScript makes it even simpler:

// Built on top of promises - looks like synchronous code
async function loadComments() {
  try {
    const user = await getUser(1);
    const posts = await getPostsForUser(user.id);
    const comments = await getCommentsForPost(posts[0].id);
    console.log("Comments:", comments);
  } catch (error) {
    console.log("Error:", error);
  }
}

loadComments();

Same promise behavior, but the syntax is simpler.


Common Promise Patterns

Promise.all() - Wait for All

Run multiple operations in parallel and wait for all:

const user = getUser(1);
const posts = getPostsForUser(1);
const comments = getComments(1);

Promise.all([user, posts, comments])
  .then(([userData, postsData, commentsData]) => {
    console.log("All data loaded");
    console.log("User:", userData);
    console.log("Posts:", postsData);
    console.log("Comments:", commentsData);
  })
  .catch((error) => {
    console.log("One or more failed:", error);
  });

If any promise fails, the whole thing fails.

Promise.race() - Wait for First

Run multiple operations and use the result of whichever finishes first:

const timeout = new Promise((resolve, reject) => {
  setTimeout(() => reject("Timeout"), 5000);
});

const apiCall = fetchData();

Promise.race([apiCall, timeout])
  .then((result) => {
    console.log("Got result:", result);
  })
  .catch((error) => {
    console.log("Failed or timed out:", error);
  });

Useful for timeout handling.

Promise.resolve() - Instant Success

Create a promise that's already fulfilled:

const instantPromise = Promise.resolve("Done!");

instantPromise.then((value) => {
  console.log(value);  // "Done!" (runs immediately)
});

Promise.reject() - Instant Failure

Create a promise that's already rejected:

const failedPromise = Promise.reject("Error!");

failedPromise.catch((error) => {
  console.log(error);  // "Error!" (runs immediately)
});

Complete Example: Building a Promise

// Create a promise that simulates an API call
function fetchUserData(userId) {
  return new Promise((resolve, reject) => {
    // Simulate network delay
    setTimeout(() => {
      // Simulate success/failure
      if (userId > 0) {
        const user = { id: userId, name: "Alice", email: "alice@example.com" };
        resolve(user);  // Success
      } else {
        reject("Invalid user ID");  // Failure
      }
    }, 1000);
  });
}

// Use the promise
fetchUserData(1)
  .then((user) => {
    console.log("Fetched user:", user.name);
    return fetchUserPosts(user.id);
  })
  .then((posts) => {
    console.log("User has", posts.length, "posts");
  })
  .catch((error) => {
    console.log("Failed:", error);
  })
  .finally(() => {
    console.log("Operation complete");
  });

// Test with invalid ID
fetchUserData(-1)
  .then((user) => {
    console.log("User:", user);
  })
  .catch((error) => {
    console.log("Error:", error);
  });

Practice Assignment

1. Create a simple promise:

// Create a promise that resolves with "Hello"
// After 1 second
// Use .then() to log the result

2. Handle both success and failure:

// Create a promise that randomly resolves or rejects
// Implement .then() and .catch()
// Test multiple times

3. Chain multiple promises:

// Create three functions that return promises
// Each one should use the result of the previous
// Chain them together with .then()
// Add error handling with .catch()

4. Compare callbacks vs promises:

// Write the same operation with callbacks
// Then rewrite with promises
// Notice the difference in readability

5. Use Promise.all():

// Create three promises that fetch different data
// Use Promise.all() to wait for all of them
// Handle success and failure

Quick Recap

  • Promises represent values that don't exist yet but will exist in the future.

  • A promise solves the callback hell problem by making asynchronous code readable.

  • Promises are like a receipt at a restaurant: you don't have the cake yet, but the receipt promises you'll get it.

  • Pending state: promise is waiting for an operation to complete.

  • Fulfilled state: operation succeeded and the promise has a value.

  • Rejected state: operation failed and the promise has an error.

  • A promise transitions from pending to either fulfilled or rejected, never both.

  • Once a promise is fulfilled or rejected, it cannot change states again.

  • resolve(value) moves a promise to fulfilled state with a value.

  • reject(error) moves a promise to rejected state with an error.

  • .then(callback) handles successful completion (fulfilled state).

  • .catch(callback) handles failure (rejected state).

  • .finally(callback) runs regardless of success or failure.

  • Promise chaining allows multiple operations in sequence with .then().then().then().

  • Each .then() returns a new promise, enabling chaining.

  • Errors in a promise chain jump to the nearest .catch().

  • Promise.all() waits for multiple promises, failing if any fails.

  • Promise.race() waits for the first promise to complete.

  • Callbacks are harder to read and lead to "callback hell."

  • Promises are cleaner and more maintainable.

  • Async/await is built on top of promises and makes code even simpler.

Promises are the foundation of modern JavaScript asynchronous programming.


If you enjoyed this article, check out my other blogs on this profile. Connect with me: LinkedIn | GitHub | X (Twitter)