Skip to main content

Command Palette

Search for a command to run...

Async Code in Node.js: Callbacks and Promises

Updated
6 min read
Async Code in Node.js: Callbacks and Promises
S
A front-end developer who’s always learning, building projects, and writing blogs to simplify web concepts

Why Async Code Exists in Node.js

Node.js is built to handle many tasks without waiting around for each one to finish. That is the whole reason async code matters.

Imagine your app needs to read a file, query a database, and call an API. These tasks can take time. If Node.js stopped and waited for each one to finish before doing anything else, the whole app would feel slow.

Instead, Node.js starts the task and moves on. When the task is done, Node.js comes back to it and continues. That is async behavior.

For an example

Think about downloading a file on your laptop.

  • You click the download button.

  • The file starts downloading in the background.

  • You do not sit there staring at the progress bar.

  • You continue browsing, watching videos, or doing other work.

  • Once the download is complete, you get a notification.

Node.js works in a similar way. It starts tasks like file reading or API calls in the background and keeps running other code. When the task finishes, it notifies your program with the result.


Start With a File Reading Example

Let's say we want to read a file called data.txt.

This is a good example because file reading is not instant. It takes some time, so Node.js handles it asynchronously.

What happens step by step

  1. Your code asks Node.js to read the file.

  2. Node.js sends that task to the system.

  3. Your program keeps running instead of freezing.

  4. When the file is ready, Node.js gives you the result.


Callback-Based Async Execution

Before promises became popular, callbacks were the main way to handle async work in Node.js.

A callback is just a function passed into another function. It gets called later when the async task finishes.

Example

const fs = require('fs');

fs.readFile('data.txt', 'utf8', function(err, data) {
  if (err) {
    console.log('Error reading file');
    return;
  }

  console.log('File content:', data);
});

console.log('This runs before the file is read');

What this code is doing

  • fs.readFile() starts reading the file.

  • The function inside it is the callback.

  • If something goes wrong, err will contain the error.

  • If the file is read successfully, data contains the content.

The last console.log() runs first because file reading does not block the program.


Problems With Nested Callbacks

Callbacks are useful, but they can become messy when one async task depends on another async task.

This is where nested callbacks start to look hard to read.

Example of nested callbacks

const fs = require('fs');

fs.readFile('user.txt', 'utf8', function(err, userData) {
  if (err) {
    console.log('Could not read user file');
    return;
  }

  fs.readFile('profile.txt', 'utf8', function(err, profileData) {
    if (err) {
      console.log('Could not read profile file');
      return;
    }

    fs.readFile('settings.txt', 'utf8', function(err, settingsData) {
      if (err) {
        console.log('Could not read settings file');
        return;
      }

      console.log(userData, profileData, settingsData);
    });
  });
});

This becomes a problem

This structure is harder to follow because:

  • indentation keeps increasing

  • each step is wrapped inside another function

  • error handling repeats again and again

  • the code starts to look like a staircase

This is often called callback hell.

Callback nesting diagram

The logic is still correct, but the readability suffers.


Promise-Based Async Handling

Promises were introduced to make async code easier to manage.

A promise is an object that represents a future result.

It can be in one of three states:

  • pending, which means the task is still running

  • fulfilled, which means the task completed successfully

  • rejected, which means the task failed

Promise example

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

fs.readFile('data.txt', 'utf8')
  .then(function(data) {
    console.log('File content:', data);
  })
  .catch(function(err) {
    console.log('Error reading file');
  });

console.log('This runs before the file is read');

What is happening here

  • fs.promises.readFile() returns a promise.

  • .then() runs when the file is read successfully.

  • .catch() runs if something goes wrong.

  • The main program still keeps moving while the file is being read.

Promise Lifecycle Flow


Why Promises Are Better Than Nested Callbacks

Promises improve readability because they let you write async steps in a cleaner chain.

Callback style

step1(function(err, result1) {
  if (err) return;
  step2(result1, function(err, result2) {
    if (err) return;
    step3(result2, function(err, result3) {
      if (err) return;
      console.log(result3);
    });
  });
});

Promise style

step1()
  .then(function(result1) {
    return step2(result1);
  })
  .then(function(result2) {
    return step3(result2);
  })
  .then(function(result3) {
    console.log(result3);
  })
  .catch(function(err) {
    console.log('Something failed');
  });

Why the promise version is easier

  • each step is visible in one chain

  • errors can be handled in one place with .catch()

  • the code flows top to bottom

  • it is easier to maintain


Callback vs Promise

Criteria Callbacks Promises
Best Use Case Simple async tasks Complex async workflows
Code Length Short and minimal Slightly longer but structured
Number of Steps Works well for 1 or 2 steps Ideal for multiple steps
Readability Can become messy with nesting Clean and linear flow
Error Handling Repeated in each callback Centralized using .catch()
Maintainability Harder as code grows Easier to manage and scale
Code Structure Nested (callback hell risk) Chained and organized

Easy Way to Remember the Difference

A callback says:

"Here is a function. Run it later when you are done."

A promise says:

"Here is a future result. I will tell you when it succeeds or fails."


Conclusion

Async behavior is the backbone of Node.js performance. It allows your application to handle multiple operations without blocking execution, which is critical for building scalable systems.

Callbacks introduced the idea of handling results later, but they become difficult to manage as complexity grows. Promises improve this by organizing async logic into a predictable and readable flow, making both development and debugging easier.

If you understand callbacks, you understand the foundation. If you use promises, you write cleaner and more maintainable code.

JavaScript Journey: From Basics to Core Concepts

Part 22 of 29

This series documents my journey of learning JavaScript and breaking down important concepts in a simple way. Each article covers a core JavaScript topic with clear explanations and beginner-friendly examples. From basic concepts to essential JavaScript features, the goal of this series is to make JavaScript easier to understand while practicing and sharing what I learn.

Up next

Sessions vs JWT vs Cookies

Authentication is something every developer uses, but not everyone fully understands. You log in, it works, and you move on. But behind the scenes, three core concepts are doing the work: sessions, co