Skip to main content

Command Palette

Search for a command to run...

Async Code in Node.js: Callbacks and Promises

Published
6 min read
Async Code in Node.js: Callbacks and Promises

One of the most important concepts in Node.js is asynchronous programming. If you’ve ever worked with file handling, APIs, or databases in Node.js, you’ve already used async code, even if you didn’t fully realize it.

At first, async programming can feel confusing because the code doesn’t always execute from top to bottom in a simple sequence. But once you understand why async code exists, callbacks and promises start making a lot more sense.

In this article, we’ll understand how asynchronous code works in Node.js, why callbacks were introduced, the problems they created, and how promises made async code cleaner and easier to manage.


Why Async Code Exists in Node.js

Node.js was designed to handle multiple tasks efficiently without blocking the main thread. Most backend operations take time to complete, such as:

  • reading files

  • querying databases

  • calling APIs

  • communicating over networks

If Node.js waited for every operation to finish before moving to the next task, the server would become slow and unresponsive.

Imagine a restaurant chef preparing food. If the chef stopped everything and waited beside the oven for one pizza to bake before taking new orders, the restaurant would quickly become chaotic.

Instead, a smart chef puts the pizza in the oven and continues preparing other dishes while waiting.

That’s exactly how Node.js handles asynchronous operations.

Starting with a File Reading Scenario

Let’s take a simple example. Imagine you want to read a large file from the system.

Here’s the synchronous version:


const fs = require("fs");

const data = fs.readFileSync("notes.txt", "utf8");

console.log(data);

console.log("Finished");

This code blocks execution. Node.js waits until the file is fully read before moving forward.

Now look at the asynchronous version :


const fs = require("fs");

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

console.log("Finished");

This time, Node.js starts reading the file and immediately continues executing the next line. That’s why "Finished" gets printed before the file content appears.

Instead of waiting, Node.js keeps moving.


Callback-Based Async Execution

The function passed inside readFile() is called a callback.

A callback is simply a function that runs later, after an operation completes.

In the previous example:


(err, data) => {
  console.log(data);
}

this function does not execute immediately. Node.js stores it temporarily and runs it once the file reading operation finishes.

Here’s the flow step-by-step:


  Start File Read
        ↓
  Node.js Continues Execution
        ↓
  File Reading Completes
        ↓
  Callback Function Executes

Callbacks became the foundation of asynchronous programming in early Node.js applications because they allowed the event loop to stay free while slow operations happened in the background.


Problems with Nested Callbacks

Callbacks solved the blocking problem, but they introduced another issue: deeply nested code.

Imagine a real-world situation where you need to:

  1. Read a file

  2. Fetch data from an API

  3. Save something into a database

  4. Send a response

Using callbacks, the code can quickly become messy.


readFile("user.txt", (err, fileData) => {
  fetchUserData(fileData, (err, userData) => {
    saveToDatabase(userData, (err, result) => {
      sendResponse(result, (err) => {
        console.log("Done");
      });
    });
  });
});

Notice how the code keeps moving deeper and deeper toward the right side.

This problem became famously known as:

“Callback Hell”

or sometimes:

“Pyramid of Doom”

The biggest issue wasn’t just appearance. Nested callbacks made code:

  • harder to read

  • difficult to debug

  • painful to maintain

  • harder to handle errors properly

As applications grew larger, developers needed a cleaner way to manage async operations.


Promise-Based Async Handling

To solve callback problems, JavaScript introduced Promises.

A Promise represents a value that may become available in the future.

Think of it like ordering food online.

When you place the order:

  • you don’t receive the food immediately

  • you receive a promise that the food will arrive later

The promise can eventually be:

  • fulfilled → operation succeeded

  • rejected → operation failed


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

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

Now the flow feels much cleaner.

Instead of nesting callbacks inside callbacks, promises allow operations to be chained in a more readable way.

Promise Lifecycle

A Promise usually goes through three states:

  
  Pending
     ↓
  Fulfilled  → Success
     OR
  Rejected   → Error

When the async operation starts, the promise is in a pending state.

After completion:

  • it becomes fulfilled if successful

  • rejected if something fails


Comparing Callback vs Promise Readability

Let’s compare both approaches side-by-side.

Callback Style


getUser(id, (err, user) => {
  getPosts(user, (err, posts) => {
    getComments(posts, (err, comments) => {
      console.log(comments);
    });
  });
});

The code becomes deeply nested very quickly.

Promise Style


getUser(id)
  .then((user) => getPosts(user))
  .then((posts) => getComments(posts))
  .then((comments) => console.log(comments))
  .catch((err) => console.log(err));

This version is much easier to read because the operations flow linearly from top to bottom.

That readability is one of the biggest reasons promises became so popular in modern JavaScript and Node.js development.


Benefits of Promises

Promises improved asynchronous programming in several important ways.

They made code:

  • cleaner and easier to read

  • easier to maintain

  • simpler to debug

  • better for error handling

  • easier to chain async operations together

Most modern Node.js APIs now support promises directly because they work much better for large-scale applications.

Promises also became the foundation for async/await, which made asynchronous code look even more synchronous and readable.


Real-World Example

Imagine an e-commerce application.

When a user opens their dashboard, the server may need to:

  • fetch user details

  • load recent orders

  • retrieve notifications

  • fetch payment information

All of these operations involve asynchronous tasks like database queries and API calls.

Without async programming, every user request would block the server while waiting for each operation to complete.

With callbacks and promises, Node.js can handle these tasks efficiently while still serving other users at the same time.

That’s one of the reasons Node.js performs so well for modern web applications.


Conclusion

Async programming is at the heart of Node.js.

Callbacks were the first major solution for handling asynchronous operations without blocking the event loop. They worked well, but deeply nested callbacks quickly became difficult to manage.

Promises improved this experience by making async code cleaner, more readable, and easier to maintain.

The important thing to remember is this:

Async code allows Node.js to stay productive while waiting for slow operations to complete.

And that single idea is what makes Node.js capable of handling massive numbers of requests efficiently.