Skip to main content

JavaScript Promises Explained for Beginners

Updated 9 min readMarkdown

Introduction

When JavaScript performs an asynchronous operation, we often don't get the result immediately.

A file may still be loading.

An API request may still be running.

A database query may still be processing.

So how do we represent a result that doesn't exist yet, but will exist sometime in the future?

That's where Promises come in.

A Promise gives us a way to represent the eventual result of an asynchronous operation.


What Problem Do Promises Solve?

Before Promises, callbacks were commonly used to handle asynchronous operations.

A callback basically says:

"When your work is finished, call this function."

For example:

function readFile(callback) {
    // asynchronous work...

    callback("File data");
}

This works well for a simple operation.

But imagine several operations that depend on each other:

Read File
   ↓
Process File
   ↓
Upload File
   ↓
Save Result

With callbacks, we may end up nesting one callback inside another:

readFile((data) => {
    processFile(data, (processedData) => {
        uploadFile(processedData, (result) => {
            saveResult(result, () => {
                console.log("Done");
            });
        });
    });
});

As the number of operations increases, the code becomes harder to read and maintain.

This is commonly known as Callback Hell.

readFile()
   │
   └── processFile()
          │
          └── uploadFile()
                 │
                 └── saveResult()
                        │
                        └── ...

Promises provide a cleaner way to represent and connect these asynchronous operations.


A Promise as a Future Value

A simple way to understand a Promise is:

A Promise represents a value that we don't have yet, but expect to receive in the future.

Think about ordering food at a restaurant.

You place the order and receive a receipt.

The receipt is not the food.

But it represents an order that will eventually produce a result:

Order Placed
     │
     ▼
   Receipt
     │
     │ waiting...
     ▼
     Food

A Promise works similarly:

Async Operation
      │
      ▼
    Promise
      │
      │ waiting...
      ▼
   Result

The Promise gives us something to work with before the final result is available.


Creating a Promise

We can create a Promise using:

new Promise()

For example:

function prepareOrder(dish) {
    return new Promise((resolve, reject) => {

        setTimeout(() => {
            resolve({
                dish,
                status: "prepared"
            });
        }, 1000);

    });
}

The function passed to new Promise() is called the executor function.

(resolve, reject) => {
    // executor
}

The Promise constructor automatically provides the two functions:

resolve
reject

We use them to tell the Promise what happened.

Success
   │
   ▼
resolve(value)

Failure
   │
   ▼
reject(error)

Promise States

A Promise has three possible states:

                Pending
               /       \
              /         \
             ▼           ▼
        Fulfilled      Rejected
         Success        Failure

Let's understand each one.


1. Pending

When we create a Promise, it starts in the pending state.

The asynchronous operation is still running.

Promise
   │
   ▼
Pending
   │
   │ "Still working..."

For example:

const orderPromise = prepareOrder("Biryani");

At this point, the order may still be getting prepared.


2. Fulfilled

If the operation succeeds, the Promise becomes fulfilled.

resolve(order);

The Promise now has a successful result.

Pending
   │
   ▼
Fulfilled
   │
   ▼
Result available

3. Rejected

If something goes wrong, the Promise becomes rejected.

reject(error);

For example:

Pending
   │
   ▼
Rejected
   │
   ▼
Error available

Once a Promise becomes fulfilled or rejected, it is settled.

It cannot go back to pending or change from fulfilled to rejected.


The Promise Lifecycle

The complete lifecycle looks like this:

                     Promise Created
                           │
                           ▼
                       PENDING
                       /     \
                      /       \
             success /         \ failure
                    ▼           ▼
               FULFILLED     REJECTED
                    │           │
                    ▼           ▼
                  value        error

A Promise therefore represents the journey from:

"Not finished yet"
        ↓
"Finished successfully"

or:

"Not finished yet"
        ↓
"Something went wrong"

The Executor Runs Immediately

There is an important detail about creating a Promise.

Consider:

console.log("start");

const promise = new Promise((resolve) => {
    console.log("executor running");

    resolve("done");
});

console.log("end");

The output is:

start
executor running
end

The executor runs immediately when the Promise is created.

So why do we call Promises asynchronous?

Because the asynchronous operation usually happens inside the executor.

For example:

const promise = new Promise((resolve) => {

    setTimeout(() => {
        resolve("done");
    }, 1000);

});

The executor runs immediately, but the timer completes later.

Promise Created
      │
      ▼
Executor Runs
      │
      ▼
Start Async Operation
      │
      ├──────────► Timer / Network / I/O
      │
      ▼
JavaScript continues
      │
      │
      ▼
Operation completes
      │
      ▼
resolve()
      │
      ▼
Promise fulfilled

This distinction is important:

The Promise executor runs synchronously. The asynchronous operation inside it may complete later.


Handling a Successful Promise

Once we have a Promise, we need a way to use its result.

That's where .then() comes in.

prepareOrder("Biryani")
    .then(order => {
        console.log(order);
    });

.then() lets us register a function that should run when the Promise is fulfilled.

If we have:

resolve({
    dish: "Biryani",
    status: "prepared"
});

then the value received by .then() is that object.

Promise
   │
   ▼
Fulfilled
   │
   ▼
.then()
   │
   ▼
Receive resolved value

For example:

prepareOrder("Biryani")
    .then(order => {
        console.log(order.dish);
        console.log(order.status);
    });

Handling Promise Failure

What if the operation fails?

That's where .catch() comes in.

prepareOrder("Biryani")
    .then(order => {
        console.log(order);
    })
    .catch(error => {
        console.error(error);
    });

If the Promise is rejected:

reject(new Error("Unable to prepare order"));

the .catch() handler can handle that error.

The flow becomes:

                    Promise
                       │
                 ┌─────┴─────┐
                 ▼           ▼
            Fulfilled     Rejected
                 │           │
                 ▼           ▼
              .then()     .catch()
                 │           │
                 ▼           ▼
              Success      Error

This gives us a clean separation between success and failure.


Callback vs Promise

Let's compare how the same asynchronous operation can be represented.

Callback

readFile("data.txt", (err, data) => {
    if (err) {
        console.error(err);
        return;
    }

    console.log(data);
});

The function receives a callback and calls it when the operation finishes.

Promise

readFile("data.txt")
    .then(data => {
        console.log(data);
    })
    .catch(err => {
        console.error(err);
    });

The operation returns a Promise.

We then decide what should happen when that Promise succeeds or fails.

The difference becomes even more visible when multiple operations are involved.


Promise Chaining

One of the biggest benefits of Promises is chaining.

Suppose we have:

Prepare Order
      ↓
Pickup Order
      ↓
Deliver Order

With callbacks, this can become deeply nested.

With Promises:

prepareOrder("Biryani")
    .then(order => pickupOrder(order))
    .then(order => deliverOrder(order))
    .then(order => {
        console.log(order);
    })
    .catch(error => {
        console.error(error);
    });

Now the flow is much easier to read.

prepareOrder()
      │
      ▼
   .then()
      │
      ▼
pickupOrder()
      │
      ▼
   .then()
      │
      ▼
deliverOrder()
      │
      ▼
   .then()
      │
      ▼
   Result

How Does Promise Chaining Work?

The important part is that a .then() handler can return another Promise.

For example:

prepareOrder("Biryani")
    .then(order => {
        return pickupOrder(order);
    })
    .then(order => {
        return deliverOrder(order);
    });

If pickupOrder() returns a Promise, the next .then() waits for that Promise to settle.

Conceptually:

Promise A
   │
   ▼
.then()
   │
   │ returns Promise B
   ▼
Promise B
   │
   ▼
.then()
   │
   │ returns Promise C
   ▼
Promise C

This is what allows us to connect asynchronous operations together without deeply nesting callbacks.


Error Propagation in a Chain

One useful property of Promise chains is that a rejection can move through the chain until it is handled.

For example:

prepareOrder("Biryani")
    .then(order => pickupOrder(order))
    .then(order => deliverOrder(order))
    .catch(error => {
        console.error("Something went wrong:", error);
    });

If prepareOrder(), pickupOrder(), or deliverOrder() rejects, the chain can reach the .catch() handler.

prepareOrder()
      │
      ▼
pickupOrder()
      │
      ▼
deliverOrder()
      │
      ▼
   Success

Any rejection
      │
      ▼
   .catch()

This is one of the reasons Promises improve error handling compared with deeply nested callbacks.


Promises and the Event Loop

There is one more important thing to understand.

When a Promise settles, its .then() or .catch() handler does not execute immediately in the middle of the current synchronous code.

Promise reactions are handled through the microtask queue.

For example:

console.log("start");

Promise.resolve("done")
    .then(value => {
        console.log(value);
    });

console.log("end");

Output:

start
end
done

Why?

The synchronous code runs first:

start
end

Then the Promise handler is processed as a microtask:

done

Conceptually:

Synchronous Code
       │
       ▼
Microtask Queue
(Promise handlers)
       │
       ▼
Execute .then()

This is why even an already fulfilled Promise doesn't execute its .then() handler immediately.


A Simple Real-World Analogy

Imagine ordering something online.

When you place an order:

Place Order
     │
     ▼
Order Confirmation

The confirmation is not the actual product.

It represents:

"Your order exists, and the final result will come later."

Then there are possible outcomes:

             Order
               │
               ▼
          Processing
          /        \
         /          \
        ▼            ▼
   Delivered      Failed

That's similar to a Promise:

            Promise
               │
               ▼
            Pending
           /       \
          /         \
         ▼           ▼
    Fulfilled     Rejected

The Promise is the representation of the future result.


Callback vs Promise: The Core Difference

The difference can be summarized like this:

CALLBACK

Start Operation
      │
      ▼
Give function to operation
      │
      ▼
Trust it to call the function
      │
      ▼
Callback receives result

While with a Promise:

PROMISE

Start Operation
      │
      ▼
Receive Promise
      │
      ▼
Attach .then() / .catch()
      │
      ▼
Promise settles
      │
      ├──► .then()  → success
      │
      └──► .catch() → failure

Promises therefore give us a more structured way to represent and handle asynchronous results.


Final Takeaway

The main purpose of a Promise is simple:

A Promise represents the eventual result of an asynchronous operation.

It starts as:

Pending

and eventually becomes:

Fulfilled

or:

Rejected

We handle those outcomes using:

.then()
.catch()

And when multiple asynchronous operations depend on each other, we can chain them:

prepareOrder()
    .then(pickupOrder)
    .then(deliverOrder)
    .catch(handleError);

Compared with deeply nested callbacks, Promise chains give us:

  • Better readability

  • Cleaner asynchronous flow

  • Easier error propagation

  • A standard way to represent future results

  • Easier composition of asynchronous operations

The key idea I keep in mind is:

A callback says:

"Call me when you're done."


A Promise says:

"Give me something representing the result,
and I'll decide what to do when it arrives."

Once this mental model is clear, concepts like async/await, Promise combinators, and the microtask queue become much easier to understand.

No comments yet. Be the first to comment.