Skip to main content

Command Palette

Search for a command to run...

Async Code in Node.js: Callbacks and Promises

Updated
4 min read

Imagine you're reading a large file in Node.js. Reading a file can take some time because Node.js needs to access the disk and fetch the data.

If Node.js waits for the file to finish reading before doing anything else, the entire application would become slow and unresponsive.

To solve this problem, Node.js uses asynchronous programming.

In this article, we'll understand:

  • Why async code exists in Node.js

  • Callback-based asynchronous execution

  • Problems with nested callbacks

  • Promise-based async handling

  • Benefits of promises

  • Callback vs Promise comparison

Why Async Code Exists in Node.js

Node.js is designed to handle many tasks efficiently without blocking the main thread.

Consider this example:

const fs = require("fs");

console.log("Start");

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

console.log("End");

Output

Start
End
(File content appears later)

What happened?

Node.js starts reading the file.

Instead of waiting for the file to finish reading:

  • It sends the file reading task to the system.

  • Continues executing the next line.

  • When the file is ready, Node.js executes the callback function.

This allows Node.js to remain fast and responsive.

Callback-Based Async Execution

A callback is simply a function passed as an argument to another function.

When an asynchronous task finishes, the callback gets executed.

Example

const fs = require("fs");

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

  console.log(data);
});

Step-by-Step Flow

  1. readFile() is called.

  2. Node.js starts reading the file.

  3. Execution continues without waiting.

  4. File reading completes.

  5. Callback function is pushed to the callback queue.

  6. Event Loop executes the callback.

  7. File content is printed.

Callback Execution Chain

Program Starts
      |
      v
readFile()
      |
      v
File Reading Starts
      |
      v
Program Continues
      |
      v
File Reading Completes
      |
      v
Callback Added to Queue
      |
      v
Event Loop Executes Callback
      |
      v
Output Displayed

Problems with Nested Callbacks

Callbacks work well for simple tasks.

But when multiple async operations depend on each other, callbacks become difficult to manage.

Example

getUser(userId, (user) => {
  getOrders(user.id, (orders) => {
    getPaymentDetails(orders, (payment) => {
      getInvoice(payment, (invoice) => {
        console.log(invoice);
      });
    });
  });
});

This creates deeply nested code.

Issues

  • Hard to read

  • Hard to debug

  • Error handling becomes messy

  • Difficult to maintain

This problem is known as Callback Hell or Pyramid of Doom.

Visual Representation

getUser()
 └── getOrders()
      └── getPaymentDetails()
           └── getInvoice()

Promise-Based Async Handling

Promises were introduced to solve callback hell.

A Promise represents a value that may be available now, later, or never.

A Promise has three states:

  1. Pending

  2. Fulfilled

  3. Rejected

Creating a Promise

const promise = new Promise((resolve, reject) => {
  const success = true;

  if (success) {
    resolve("Operation Successful");
  } else {
    reject("Operation Failed");
  }
});

Using Promises

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

Output

Operation Successful

File Reading Using Promises

const fs = require("fs").promises;

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

The code is cleaner and easier to understand.

Promise Lifecycle Flow

Promise Created
       |
       v
     Pending
       |
   ---------
   |       |
   v       v
Resolved Rejected
   |       |
   v       v
 then()  catch()

Callback vs Promise Readability

Callback Version

getUser(userId, (user) => {
  getOrders(user.id, (orders) => {
    getPaymentDetails(orders, (payment) => {
      console.log(payment);
    });
  });
});

Promise Version

getUser(userId)
  .then((user) => getOrders(user.id))
  .then((orders) => getPaymentDetails(orders))
  .then((payment) => console.log(payment))
  .catch((error) => console.log(error));

The Promise version is flatter, cleaner, and easier to maintain.

Benefits of Promises

  1. Better Readability

Code is easier to understand compared to nested callbacks.

  1. Easier Error Handling

A single .catch() can handle errors from the entire chain.

  1. Avoids Callback Hell

Promises eliminate deeply nested structures.

  1. Better Maintainability

Large applications become easier to manage.

  1. Foundation for Async/Await

Modern JavaScript async/await is built on top of Promises.

Conclusion

Asynchronous programming is one of the most important concepts in Node.js.

Callbacks were the original way to handle asynchronous tasks, but they often led to callback hell when multiple operations were chained together.

Promises solved many of these problems by providing cleaner syntax, better error handling, and improved readability.

Today, Promises form the foundation of modern asynchronous JavaScript and make building scalable Node.js applications much easier.