Skip to main content

Async Code in Node.js: Callbacks and Promises

Updated 13 min readMarkdown

Introduction

When we write code, some operations take time.
Reading a file, making a database query, calling an API, or communicating over a network may not finish immediately.

If JavaScript simply waited for every such operation to finish, the program could become unresponsive.

This is where asynchronous programming comes in.

In this article, we'll understand how Node.js handles asynchronous work using callbacks, why deeply nested callbacks become difficult to manage, and how Promises provide a cleaner way to handle the same problem.


Why Does Async Code Exist in Node.js?

JavaScript executes code on a single main thread.

But being single-threaded does not mean Node.js has to wait for every operation to finish.

For example, consider reading a file:

const fs = require("fs");

const data = fs.readFileSync("large-file.txt", "utf8");

console.log(data);

With the synchronous version, the program waits until the file has been completely read.

But file operations can take time.

Instead, Node.js provides an asynchronous version:

fs.readFile("large-file.txt", "utf8", (err, data) => {
    console.log(data);
});

console.log("Program continues...");

Here, Node.js can start the file-reading operation and continue executing other JavaScript code.

Conceptually:

JavaScript
    │
    ▼
Start File Read
    │
    ├──────────────► File System
    │                    │
    │                    │ Reading...
    ▼                    │
Continue Execution       │
    │                    │
    ▼                    │
Do Other Work            │
                          │
                          ▼
                     File Complete
                          │
                          ▼
                      Callback
                          │
                          ▼
                    Handle Result

Depending on the type of asynchronous operation, Node.js can rely on operating-system mechanisms or libuv's mechanisms to perform the work.

The important idea is:

JavaScript does not have to sit and wait for every I/O operation to finish.


Before Promises: Callbacks

Before Promises became a standard way of handling asynchronous operations, JavaScript commonly used callbacks.

A callback is nothing magical.

It is simply a function passed as an argument to another function with the instruction:

"Do your work, and when you're completely finished, call me back."

For example:

function pickupOrderCB(order, cb) {
    setTimeout(() => {
        cb(null, {
            ...order,
            status: "picked-up"
        });
    }, 100);
}

Here, cb is the callback.

When the asynchronous operation finishes, we call:

cb(null, updatedOrder);

So the basic flow is:

Start Operation
      │
      ▼
Do Async Work
      │
      ▼
Work Completed
      │
      ▼
Call Callback
      │
      ▼
Continue

Callback-Based Async Execution

Let's use a restaurant order as an example.

Suppose we have three operations:

Prepare Order
      ↓
Pick Up Order
      ↓
Deliver Order

We cannot pick up the order before it has been prepared.

Similarly, we cannot deliver it before it has been picked up.

So each operation depends on the previous one.

We can represent these operations using callbacks:

function prepareOrderCB(dish, cb) {
    setTimeout(() => {
        cb(null, {
            dish,
            status: "prepared"
        });
    }, 100);
}

function pickupOrderCB(order, cb) {
    setTimeout(() => {
        cb(null, {
            ...order,
            status: "picked-up"
        });
    }, 100);
}

function deliverOrderCB(order, cb) {
    setTimeout(() => {
        cb(null, {
            ...order,
            status: "delivered"
        });
    }, 100);
}

Now we can execute them in order:

prepareOrderCB("Biryani", (err, order) => {
    if (err) return console.log(err);

    pickupOrderCB(order, (err, order) => {
        if (err) return console.log(err);

        deliverOrderCB(order, (err, order) => {
            if (err) return console.log(err);

            console.log(`${order.dish}: ${order.status}`);
        });
    });
});

The flow looks like this:

prepareOrderCB()
       │
       ▼
   callback
       │
       ▼
pickupOrderCB()
       │
       ▼
   callback
       │
       ▼
deliverOrderCB()
       │
       ▼
   callback
       │
       ▼
     Result

For three operations, this is still manageable.

But what happens if we have ten operations?


The Problem With Nested Callbacks

Imagine our application now has to perform:

Prepare
  ↓
Pickup
  ↓
Scan
  ↓
Ship
  ↓
Track
  ↓
Deliver
  ↓
Invoice

With callbacks, every next operation may have to be placed inside the previous callback.

The code starts moving further and further to the right.

This is commonly known as:

Callback Hell

It is also called the Pyramid of Doom.

prepare()
   │
   └── pickup()
         │
         └── scan()
               │
               └── ship()
                     │
                     └── track()
                           │
                           └── deliver()

The indentation itself isn't the real problem.

The deeper problem is that our asynchronous control flow becomes harder to understand and maintain.


Why Callback Hell Is Difficult

1. Readability

As the number of dependent operations increases, the code becomes deeply nested.

Instead of being able to read the flow from top to bottom, we have to keep moving deeper into callbacks.


2. Repeated Error Handling

With callback-based code, we may have to repeatedly write:

if (err) return console.log(err);

at every level.

For example:

prepareOrderCB("Biryani", (err, order) => {
    if (err) return console.log(err);

    pickupOrderCB(order, (err, order) => {
        if (err) return console.log(err);

        deliverOrderCB(order, (err, order) => {
            if (err) return console.log(err);

            console.log(order);
        });
    });
});

As the number of operations increases, managing these success and failure paths becomes cumbersome.


3. Inversion of Control

There is another subtle problem.

When we pass our callback to another function, we are trusting that function to call it correctly.

For example:

prepareOrderCB("Biryani", callback);

We are effectively saying:

"I'm giving you this function. Please call it when you're finished."

But what if the other function:

  • never calls the callback?

  • calls it twice?

  • calls it with the wrong data?

  • calls it at the wrong time?

Now our code has less control over the flow.

This is one reason callback-heavy asynchronous code can become fragile.


Enter the Promise

Promises provide another way to represent asynchronous results.

A useful mental model is:

A Promise is like a receipt for something that will give us a result in the future.

Imagine ordering food.

The restaurant doesn't immediately give you the food.

Instead, you get a receipt representing your order.

The order is still being processed.

Later, you either receive the food or learn that something went wrong.

A Promise works in a similar way.

Start Async Operation
        │
        ▼
      Promise
        │
        ▼
   Result in Future

Instead of passing a callback directly into every operation, the operation can return a Promise.


Promise States

A Promise has three states:

             Pending
             /     \
            /       \
           ▼         ▼
      Fulfilled    Rejected
       Success      Failure

Pending

The operation is still running.

"Order is being prepared..."

Fulfilled

The operation completed successfully.

"Order prepared successfully."

Rejected

The operation failed.

"Something went wrong."

Once a Promise moves from pending to either fulfilled or rejected, its state is settled and cannot change again.


Creating a Promise

We can create a Promise using:

new Promise(...)

For example:

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

        setTimeout(() => {
            if (!dish) {
                reject(new Error("Dish is required"));
                return;
            }

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

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

new Promise((resolve, reject) => {
    // executor
});

What Are resolve and reject?

A common beginner question is:

"Where did resolve and reject come from? I never passed them."

The Promise constructor provides them automatically.

A simplified mental model is:

function Promise(executor) {
    const resolve = () => {};
    const reject = () => {};

    executor(resolve, reject);
}

This is not the actual implementation, but it helps us understand the idea.

When we write:

new Promise((resolve, reject) => {
    // ...
});

JavaScript provides these two functions:

resolve → tells the Promise the operation succeeded

reject  → tells the Promise the operation failed

The Executor Runs Immediately

This is an important detail.

Many beginners assume the executor waits until .then() is called.

It does not.

Consider:

console.log("start");

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

    resolve("done");
});

console.log("end");

Output:

start
executor running
end

The executor runs immediately when the Promise is created.

So where does the asynchronous behavior come from?

Usually, the asynchronous operation is started inside the executor.

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

    setTimeout(() => {
        resolve("Order prepared");
    }, 2000);

});

The executor runs immediately.

But the timer finishes later.

During that time, JavaScript can continue doing other work.

Promise Created
      │
      ▼
Executor Runs Immediately
      │
      ▼
Start Async Operation
      │
      ├──────────────► Timer / I/O / Network
      │
      ▼
JavaScript Continues
      │
      ▼
Operation Completes
      │
      ▼
resolve() / reject()
      │
      ▼
Promise Settles

This distinction is important:

The Promise executor is synchronous, while the asynchronous operation started inside it may complete later.


Handling Promises With .then()

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

That's where:

.then()

comes in.

For example:

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

.then() registers a handler that runs when the Promise is fulfilled.

Conceptually:

promise.then(onFulfilled, onRejected);

Both handlers are optional.

The fulfilled handler receives the value passed to:

resolve(value);

The rejection handler receives the reason passed to:

reject(error);

In practice, we usually handle rejection using .catch().


Promise Chaining

Now let's rewrite our restaurant workflow.

prepareOrder("Paneer Tikka")
    .then(order => pickupOrder(order))
    .then(order => deliverOrder(order))
    .then(order => {
        console.log(`${order.dish}: ${order.status}`);
    })
    .catch(err => {
        console.error(err);
    });

Now the flow is much easier to see:

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

Any failure
      │
      ▼
   .catch()

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

For example:

.then(order => pickupOrder(order))

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

That's what allows us to build a chain.


.catch() — Handling Errors

With callbacks, we might repeatedly write:

if (err) return console.log(err);

With Promises, we can handle rejection using:

.catch()

For example:

prepareOrder("Paneer Tikka")
    .then(order => pickupOrder(order))
    .then(order => deliverOrder(order))
    .catch(err => {
        console.error(err);
    });

Instead of placing error handling inside every nested callback, we can have a common error-handling path for the chain.

prepare
   │
   ▼
pickup
   │
   ▼
deliver
   │
   ▼
success

Any failure
     │
     ▼
  .catch()

This is one of the reasons Promise-based code is easier to maintain.


Callback vs Promise

Let's compare the same workflow.

Callback Version

prepareOrderCB("Biryani", (err, order) => {
    if (err) return console.error(err);

    pickupOrderCB(order, (err, order) => {
        if (err) return console.error(err);

        deliverOrderCB(order, (err, order) => {
            if (err) return console.error(err);

            console.log(order);
        });
    });
});

Promise Version

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

The operations are conceptually the same.

But the structure is different.

CALLBACK

prepare
   │
   └── pickup
         │
         └── deliver
               │
               └── result


PROMISE

prepare
   ↓
.then()
   ↓
pickup
   ↓
.then()
   ↓
deliver
   ↓
.then()
   ↓
result

The Promise version gives us a much flatter representation of the asynchronous flow.


Why Promises Are Useful

Promises aren't just about making code look prettier.

They provide a standard abstraction for representing future asynchronous results.

1. Better Readability

Instead of deeply nesting callbacks, we can chain operations:

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

The sequence becomes easier to follow.


2. Better Error Propagation

A Promise chain can have a common rejection path:

promise
    .then(stepOne)
    .then(stepTwo)
    .then(stepThree)
    .catch(handleError);

This is much cleaner than manually checking errors at every callback level.


3. Easier Composition

Promises can be combined using methods such as:

Promise.all()
Promise.allSettled()
Promise.race()
Promise.any()

For example, if several independent operations must all succeed:

Promise.all([request1, request2, request3])
    .then(results => {
        console.log(results);
    })
    .catch(err => {
        console.error(err);
    });

Promises therefore give us tools for composing multiple asynchronous operations instead of handling every operation in isolation.


4. Clear Separation of Responsibilities

Callbacks often mix together:

start operation
      +
handle result
      +
handle error

Promises separate these ideas.

An operation can return a Promise:

const orderPromise = prepareOrder("Biryani");

And another part of the code can decide what to do with the result:

orderPromise
    .then(order => console.log(order))
    .catch(err => console.error(err));

This separation gives us more control over asynchronous flow.


One Important Connection: Promises and the Event Loop

We have already seen the Event Loop in previous Node.js topics.

Promises add one important piece to that picture: the Microtask Queue.

Consider:

console.log("Fury Called");

setTimeout(() => {
    console.log("Thor Responded");
}, 0);

Promise.resolve().then(() => {
    console.log("Iron Man Responded");
});

console.log("Mission Running");

The output is:

Fury Called
Mission Running
Iron Man Responded
Thor Responded

Why?

The synchronous code runs first:

Fury Called
Mission Running

The Promise handler is scheduled as a microtask.

The timer callback is scheduled for a later task.

After the current synchronous work finishes, pending microtasks are processed before moving on to the next task.

So:

Synchronous Code
       │
       ▼
Microtask Queue
(Promise handlers)
       │
       ▼
Next Task
(Timer callback)

This is why the Promise callback runs before the setTimeout() callback in this example.

Even:

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

console.log("end");

produces:

end
done

The Promise is already fulfilled, but the .then() handler still runs asynchronously through the microtask mechanism.


The Journey From Callbacks to Promises

The evolution can be summarized like this:

Asynchronous Operation
        │
        ▼
    Callbacks
        │
        ▼
Nested Callbacks
        │
        ▼
   Callback Hell
        │
        ▼
     Promises
        │
        ├── .then()
        ├── .catch()
        └── Promise Combinators

The goal was not simply to "remove callbacks."

Callbacks are still a fundamental part of JavaScript.

The bigger change was introducing a better abstraction for representing and composing asynchronous results.


Final Takeaway

The main idea behind asynchronous programming in Node.js is simple:

Don't make the JavaScript execution wait unnecessarily for slow I/O operations.

Callbacks gave us a way to say:

"When you're finished, call this function."

But when many operations depend on each other, callbacks can lead to deeply nested code and difficult error handling.

Promises give us another model:

"Give me an object representing the future result, and I'll decide how to handle it."

That gives us:

  • Cleaner asynchronous flow

  • Promise chaining

  • Better error propagation

  • Easier composition

  • A standard representation of future results

And this eventually leads to the syntax most developers use today:

Callbacks
    ↓
Promises
    ↓
async / await

The underlying asynchronous problem remains the same.

What changes is how clearly and safely we can express the flow of that asynchronous work.

No comments yet. Be the first to comment.