# JavaScript Promises Explained: From Callbacks to Async/Await

JavaScript often needs to wait for things that don't finish immediately.

Fetching data from an API, waiting for a timer, reading a file, or saving information somewhere can all take time.

But JavaScript shouldn't stop doing everything else while waiting.

So how do we handle a value that **isn't available yet**?

JavaScript has evolved several ways to handle asynchronous operations:

```text
Callbacks
    ↓
Promises
    ↓
async/await
```

In this article, we'll understand that evolution and, most importantly, learn **how Promises work, why they matter, and when to use them**.

* * *

## The Problem: Some Results Take Time

Consider an API request:

```js
const response = fetch("/api/users");
```

The response doesn't arrive instantly.

The application has to:

1.  Start the request
    
2.  Wait for the server
    
3.  Receive the response
    
4.  Process the result
    

The important part is that we don't want JavaScript to freeze while waiting.

Instead, we need a way to say:

> "Start this operation. When the result is ready, do something with it."

That's the problem asynchronous programming needs to solve.

* * *

## 1\. Callbacks: The Original Approach

One of the earliest common solutions was the **callback**.

A callback is simply a function that you give to another function so it can call it later.

```js
getUser((user) => {
  console.log(user);
});
```

The idea is straightforward:

```text
Start operation
      ↓
Wait
      ↓
Result available
      ↓
Run callback
```

Callbacks work well for simple operations.

The problem appears when multiple asynchronous operations depend on each other.

For example:

```js
getUser((user) => {
  getOrders(user.id, (orders) => {
    getOrderDetails(orders[0], (order) => {
      console.log(order);
    });
  });
});
```

Now the code becomes deeply nested.

As more operations are added, the flow becomes harder to read and maintain.

This is commonly called **callback hell**.

The problem isn't that callbacks are bad.

The problem is that deeply nested callbacks make complex asynchronous flows difficult to manage.

And this is where Promises become useful.

* * *

## 2\. Promises: A Better Way to Represent Future Results

A **Promise represents the eventual result of an asynchronous operation**.

Think of it as a placeholder for a value you don't have yet.

For example:

```js
const promise = fetch("/api/users");
```

You don't have the server's response immediately.

But you have a Promise representing the response that will eventually arrive.

Think of it like this:

```text
              Promise
                 │
          "I don't have
           the result yet"
                 │
          ┌──────┴──────┐
          ↓             ↓
       Success        Failure
          ↓             ↓
       Result          Error
```

This is the core idea behind Promises.

* * *

## 3\. The Three States of a Promise

Every Promise has one of three states.

### Pending

The operation is still in progress.

```text
"Waiting..."
```

### Fulfilled

The operation completed successfully.

```text
"Here's your result."
```

### Rejected

The operation failed.

```text
"Something went wrong."
```

The lifecycle looks like this:

```text
             PENDING
                │
          ┌─────┴─────┐
          ↓           ↓
     FULFILLED     REJECTED
          │           │
          ↓           ↓
       Success       Error
```

A Promise starts as **pending** and eventually becomes either **fulfilled** or **rejected**.

Once it settles, its state doesn't change again.

* * *

## 4\. Creating a Promise

You can create a Promise using the `Promise` constructor:

```js
const promise = new Promise((resolve, reject) => {
  // operation
});
```

It provides two functions:

*   `resolve()` → marks the Promise as fulfilled
    
*   `reject()` → marks the Promise as rejected
    

For example:

```js
const promise = new Promise((resolve, reject) => {
  resolve("Operation completed");
});
```

The Promise is now fulfilled with `"Operation completed"`.

Or:

```js
const promise = new Promise((resolve, reject) => {
  reject("Operation failed");
});
```

Now the Promise is rejected.

In real applications, you will often **consume** Promises returned by APIs and libraries rather than create them yourself.

For example:

```js
fetch("/api/users");
```

`fetch()` returns a Promise.

* * *

## 5\. Handling a Successful Promise

To handle a fulfilled Promise, use `.then()`.

```js
promise.then((result) => {
  console.log(result);
});
```

For example:

```js
const promise = new Promise((resolve) => {
  setTimeout(() => {
    resolve("Data received");
  }, 2000);
});

promise.then((result) => {
  console.log(result);
});
```

After the operation completes:

```text
Data received
```

The basic idea is:

```text
Promise
   ↓
.then()
   ↓
Handle successful result
```

* * *

## 6\. Handling Promise Errors

What happens when the operation fails?

Use `.catch()`.

```js
const promise = new Promise((resolve, reject) => {
  reject("Request failed");
});

promise
  .then((result) => {
    console.log(result);
  })
  .catch((error) => {
    console.log(error);
  });
```

Now the rejected Promise is handled by `.catch()`.

Think of it as:

```text
.then()  → successful result
.catch() → error
```

You can also use `.finally()` when something should happen regardless of success or failure:

```js
promise
  .then((result) => {
    console.log(result);
  })
  .catch((error) => {
    console.log(error);
  })
  .finally(() => {
    console.log("Finished");
  });
```

For example, you might use `finally()` to hide a loading indicator after an API request finishes.

* * *

## 7\. Promise Chaining

One of the most useful features of Promises is **chaining**.

Suppose we need to:

```text
Get user
   ↓
Get their orders
   ↓
Get order details
   ↓
Display result
```

With Promises:

```js
getUser()
  .then((user) => {
    return getOrders(user.id);
  })
  .then((orders) => {
    return getOrderDetails(orders[0]);
  })
  .then((order) => {
    console.log(order);
  })
  .catch((error) => {
    console.log(error);
  });
```

This is easier to follow because the asynchronous flow moves mostly from **top to bottom** instead of becoming deeply nested.

### How does the chain work?

A `.then()` returns a Promise.

If you return another Promise from it:

```js
.then((user) => {
  return getOrders(user.id);
})
```

the next `.then()` waits for that returned Promise.

So:

```text
Promise 1
   ↓
.then()
   ↓
Promise 2
   ↓
.then()
   ↓
Promise 3
   ↓
.then()
   ↓
Result
```

This is the heart of Promise chaining.

* * *

## 8\. Why Returning Matters

Consider:

```js
getUser()
  .then((user) => {
    return getOrders(user.id);
  })
  .then((orders) => {
    console.log(orders);
  });
```

The `return` passes the Promise from `getOrders()` to the next step.

Without it:

```js
getUser()
  .then((user) => {
    getOrders(user.id);
  })
  .then((orders) => {
    console.log(orders);
  });
```

the next `.then()` doesn't wait for `getOrders()` in the same way.

So when building chains, remember:

> **Return the Promise you want the next** `.then()` **to wait for.**

* * *

## 9\. Callbacks vs Promises

Here's the difference at a high level.

### Callback style

```js
getUser((user) => {
  getOrders(user.id, (orders) => {
    getOrderDetails(orders[0], (order) => {
      console.log(order);
    });
  });
});
```

### Promise style

```js
getUser()
  .then((user) => getOrders(user.id))
  .then((orders) => getOrderDetails(orders[0]))
  .then((order) => console.log(order))
  .catch((error) => console.log(error));
```

The Promise version gives the flow a more predictable structure:

```text
Operation
   ↓
Operation
   ↓
Operation
   ↓
Success

Any failure
   ↓
.catch()
```

Promises don't eliminate asynchronous complexity.

They provide a **better structure for managing it**.

* * *

## 10\. Where Promises Are Used

Promises are everywhere in modern JavaScript.

### API requests

```js
fetch("/api/products")
  .then((response) => response.json())
  .then((products) => {
    console.log(products);
  })
  .catch((error) => {
    console.log(error);
  });
```

### Timers

You can wrap timer-based operations in Promises:

```js
const wait = (ms) =>
  new Promise((resolve) => {
    setTimeout(resolve, ms);
  });

wait(2000).then(() => {
  console.log("Two seconds passed");
});
```

### File operations

Node.js APIs provide Promise-based versions of many file operations:

```js
import { readFile } from "node:fs/promises";

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

The common pattern is:

```text
Start asynchronous operation
          ↓
       Promise
          ↓
   ┌──────┴──────┐
   ↓             ↓
Success        Failure
   ↓             ↓
 .then()       .catch()
```

* * *

## 11\. Then Came async/await

Promises solved a lot of readability problems.

But long chains can still become difficult to read.

That's where `async/await` comes in.

`async/await` is essentially a cleaner way to work with Promises.

Consider:

```js
getUser()
  .then((user) => getOrders(user.id))
  .then((orders) => getOrderDetails(orders[0]))
  .then((order) => {
    console.log(order);
  })
  .catch((error) => {
    console.log(error);
  });
```

With `async/await`:

```js
async function loadOrder() {
  try {
    const user = await getUser();
    const orders = await getOrders(user.id);
    const order = await getOrderDetails(orders[0]);

    console.log(order);
  } catch (error) {
    console.log(error);
  }
}
```

The second version reads more like normal step-by-step code:

```text
Get user
   ↓
Get orders
   ↓
Get order details
   ↓
Display order
```

That's the major benefit of `async/await`.

* * *

## 12\. How async/await Actually Relates to Promises

This distinction is important:

`async/await` **does not replace Promises.**

It works **with** Promises.

An `async` function always returns a Promise:

```js
async function getMessage() {
  return "Hello";
}
```

So:

```js
getMessage().then((message) => {
  console.log(message);
});
```

works because `getMessage()` returns a Promise.

And `await` is used to wait for a Promise to settle:

```js
const message = await getMessage();
```

You can think of it like this:

```text
Callbacks
    ↓
A way to handle results later

Promises
    ↓
Represent and manage future results

async/await
    ↓
Cleaner syntax for working with Promises
```

So learning Promises is important even if you mostly write `async/await`.

* * *

## 13\. When Should You Use Each?

### Callbacks

Callbacks are still useful when:

*   Working with callback-based APIs
    
*   Handling event listeners
    
*   Passing a function to execute later
    

For example:

```js
button.addEventListener("click", () => {
  console.log("Clicked");
});
```

Not every callback is an asynchronous workflow that needs Promises.

* * *

### Promises

Use Promises when:

*   An operation produces a future result
    
*   You need to compose asynchronous operations
    
*   You're working with Promise-based APIs
    
*   You want `.then()`, `.catch()`, or Promise utilities
    

Promises are especially important for understanding how asynchronous JavaScript actually works.

* * *

### async/await

Use `async/await` when:

*   You want Promise-based code to read sequentially
    
*   You have multiple dependent asynchronous operations
    
*   You want straightforward `try/catch` error handling
    
*   A Promise chain is becoming difficult to read
    

In modern JavaScript applications, `async/await` is often the most readable way to consume Promises.

* * *

## 14\. A Simple Model

Don't think of a Promise as "a delayed value."

Think of it as:

> **A container representing the future outcome of an operation.**

That outcome can be:

```text
             Promise
                │
             Pending
                │
        ┌───────┴───────┐
        ↓               ↓
    Fulfilled        Rejected
        │               │
        ↓               ↓
    .then()          .catch()
```

And when you want to write that Promise-based flow in a more sequential style:

```js
async function run() {
  try {
    const result = await somePromise();
    console.log(result);
  } catch (error) {
    console.log(error);
  }
}
```

* * *

## Promise Cheat Sheet

| Concept | Meaning |
| --- | --- |
| Promise | Represents an eventual result |
| Pending | Operation hasn't finished |
| Fulfilled | Operation succeeded |
| Rejected | Operation failed |
| `resolve()` | Fulfill a Promise |
| `reject()` | Reject a Promise |
| `.then()` | Handle success |
| `.catch()` | Handle errors |
| `.finally()` | Run after settlement |
| Chaining | Connect multiple Promise operations |
| `async` | Makes a function return a Promise |
| `await` | Waits for a Promise inside an async function |

* * *

## Final Takeaway

JavaScript's approach to asynchronous code has evolved:

```text
Callbacks
   ↓
Promises
   ↓
async/await
```

Callbacks provided a way to run code when an operation finished, but deeply nested callbacks could become difficult to manage.

Promises introduced a structured representation of a **future result**, with clear states and methods for handling success and failure.

Then `async/await` provided a cleaner syntax for consuming those Promises.

The most important thing to remember is:

> **Promises are the foundation.** `async/await` **is a cleaner way to work with them.**

Once you understand that a Promise starts **pending**, becomes **fulfilled or rejected**, and can be handled with `.then()` and `.catch()`, asynchronous JavaScript becomes much easier to reason about.

And when several asynchronous operations need to happen in sequence, Promise chaining and `async/await` give you readable ways to express that flow.
