Skip to main content

Command Palette

Search for a command to run...

JavaScript Promises Explained: From Callbacks to Async/Await

Updated
10 min readView as Markdown
JavaScript Promises Explained: From Callbacks to Async/Await
M
Software Engineer exploring software, networking, and systems. I document what I learn, share practical insights, and learn from the community.

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:

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:

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.

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

The idea is straightforward:

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:

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:

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:

              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.

"Waiting..."

Fulfilled

The operation completed successfully.

"Here's your result."

Rejected

The operation failed.

"Something went wrong."

The lifecycle looks like this:

             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:

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:

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

The Promise is now fulfilled with "Operation completed".

Or:

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:

fetch("/api/users");

fetch() returns a Promise.


5. Handling a Successful Promise

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

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

For example:

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

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

After the operation completes:

Data received

The basic idea is:

Promise
   ↓
.then()
   ↓
Handle successful result

6. Handling Promise Errors

What happens when the operation fails?

Use .catch().

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:

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

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

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:

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

With Promises:

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:

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

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

So:

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

This is the heart of Promise chaining.


8. Why Returning Matters

Consider:

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:

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

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

Promise style

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:

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

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:

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:

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

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

The common pattern is:

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:

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

With async/await:

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:

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:

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

So:

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

works because getMessage() returns a Promise.

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

const message = await getMessage();

You can think of it like this:

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:

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:

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

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

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:

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.

5 views