Skip to main content

Command Palette

Search for a command to run...

JavaScript Promises Explained for Beginners

Updated
7 min read
JavaScript Promises Explained for Beginners

Why Promises Exist

The Problem with Callbacks

Before promises existed, developers used callbacks to handle asynchronous tasks. A callback is just a function passed into another function to be executed later. However, when you have multiple asynchronous tasks that depend on each other, callbacks create deeply nested, hard-to-read code.

Think of trying to read a book where every new paragraph is indented further and further to the right. By the time you reach the middle of the page, the text is squeezed against the edge, making it incredibly frustrating to read.

step1(() => {
  step2(() => {
    step3(() => {
      console.log("Done");
    });
  });
});

Let us look at exactly what happens here

  1. We start step1. We cannot start step2 until step1 is finished.

  2. Inside the callback for step1, we start step2.

  3. Inside the callback for step2, we start step3.

  4. This creates a messy triangle shape known as callback hell. It makes error handling difficult and destroys the readability of your code.

What is a Promise

What problem are we solving? We need a way to manage asynchronous code without nesting functions inside each other. A promise in JavaScript represents a value that is not available yet, but will be available in the future.

Think of going to a busy fast-food restaurant. You order your food and pay. The cashier does not hand you a burger immediately. Instead, they hand you a receipt with an order number. That receipt is a "promise". You can sit down and wait, knowing that eventually, the receipt will turn into your meal.

const promise = new Promise((resolve, reject) => {
  resolve("Success");
});

Here is a step-by-step explanation:

  1. We create a new promise using new Promise().

  2. The promise takes a function with two parameters: resolve and reject.

  3. If the background task finishes successfully, we call resolve() and pass it the final data.

  4. If something goes wrong, we would call reject() to report an error.

Promise States

A promise is not just a static object. It is a living process that moves through specific phases. A promise can only be in one of three states at any given time.

Think of an online shopping delivery. When you click buy, your package is "pending" delivery. If it arrives at your door, the delivery is "fulfilled". If the delivery truck breaks down and the package is lost, the delivery is "rejected".

const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => resolve("Done"), 1000);
});

Let us explain these state transitions

  1. Pending: The moment we create myPromise, it enters the pending state. The timer is ticking, but no data is ready yet.

  2. Fulfilled: After exactly one second, the setTimeout finishes and calls resolve("Done"). The promise permanently changes its state to fulfilled.

  3. Rejected: If we had a network error and called reject("Error") instead, the promise would permanently change its state to rejected.

Working with Promises

Handling Success and Failure

Creating a promise is only half the battle. We also need to know how to extract the data when it succeeds, or catch the error when it fails. We do this using .then() and .catch().

Imagine your restaurant receipt again. When your number is called, you take your receipt to the counter to claim your food (using .then()). If the kitchen announces they ran out of ingredients, you take your receipt back to get a refund (using .catch()).

myPromise
  .then(result => console.log(result))
  .catch(error => console.log(error));

Here is the step-by-step execution

  1. We attach .then() directly to our promise. JavaScript waits quietly until the promise is fulfilled.

  2. Once fulfilled, the data inside resolve() is passed directly into the result variable, and we print it.

  3. We also attach .catch(). If the promise is rejected at any point, JavaScript skips .then() completely and runs the code inside .catch().

Promise Lifecycle

To fully grasp promises, you must understand their complete lifecycle flow. First, the promise is created and immediately enters the pending state. While it is pending, your main JavaScript code continues running. Eventually, the background task finishes, and the promise becomes either resolved (success) or rejected (failure). Finally, the result is handled by your attached .then() or .catch() methods.

Promise Chaining

One of the most powerful features of promises is chaining. We can connect multiple asynchronous steps cleanly, one after another, without nesting them.

Think of a car assembly line. Station 1 builds the frame and passes it to Station 2. Station 2 adds the engine and passes it to Station 3. Each station does its job and hands the result down the line.

getUser()
  .then(user => getOrders(user))
  .then(orders => console.log(orders))
  .catch(err => console.log("An error occurred:", err));

Let us explain this clearly

  1. We call getUser(), which returns a promise.

  2. When the user is found, the first .then() runs. We take that user data and pass it into getOrders(), which returns another promise.

  3. When the orders are found, the second .then() runs, and we print the orders.

  4. If an error occurs at any point in this entire chain, execution instantly jumps down to the single .catch() at the bottom.

Why Promises are Better than Callbacks

Why were callbacks not enough? Promises vastly improve our code in three main ways

  • Avoid nesting: Promises flatten the code into a straight, vertical line.

  • Improve readability: Reading .then() and .catch() flows like natural English.

  • Better error handling: Instead of writing error checks for every single nested callback, a promise chain only needs one .catch() at the very end to handle errors from any step.

Practical Understanding

Real-World Example

Let us look at a realistic example where we simulate fetching a user profile from a database.

function fetchUser() {
  return new Promise(resolve => {
    console.log("Fetching data...");
    setTimeout(() => {
      resolve({ name: "Purakhnath" });
    }, 1000);
  });
}

fetchUser()
  .then(user => {
    console.log("Welcome back, " + user.name);
  })
  .catch(err => {
    console.log("Something went wrong");
  });

Let us explain the flow of this real-world scenario

  1. We define a function fetchUser that returns a new promise.

  2. Inside the promise, we start a background task using setTimeout to simulate network latency.

  3. We immediately call fetchUser(). It prints "Fetching data..." and hands us back a pending promise.

  4. One second later, the timer finishes and calls resolve() with our user object.

  5. Our attached .then() catches that data and prints the welcome message.

Common Mistakes

When beginners start using promises, they often make these structural mistakes

  • Forgetting return in chaining: If you run another asynchronous function inside a .then(), you must explicitly return it. Otherwise, the next .then() will not wait for it to finish.

  • Misunderstanding async timing: Developers sometimes try to access a promise's result outside of the .then() block. The data only exists inside the .then().

  • Not handling errors: If a promise is rejected and you forgot to add a .catch(), JavaScript will throw an "unhandled promise rejection" error, which can crash your application.

Summary

  • Promises represent future values from asynchronous operations.

  • They have three distinct states: pending, fulfilled, and rejected.

  • They drastically improve readability by flattening nested code into clean vertical lines.

  • They allow multiple asynchronous tasks to be linked together via chaining.

  • They solve the core structural problems created by callback hell.

In the next article, we will explore async and await in JavaScript and see how they make asynchronous code even cleaner.

More from this blog