Skip to main content

Command Palette

Search for a command to run...

Async Code in Node.js: Callbacks and Promises

Updated
7 min read
Async Code in Node.js: Callbacks and Promises

1) Why Async Code Exists in Node.js (with File Reading Scenario)

Imagine your program needs to read a file. This takes time. If Node.js waits for the file to fully load before doing anything else, the whole program will pause — which is inefficient.

Instead, Node.js uses asynchronous code, so it can start reading the file and continue doing other tasks at the same time.

Example: File Reading

console.log("Start");

readFile("file.txt", (err, data) => {
  console.log("File content:", data);
});

console.log("End");

Output Flow

Start
End
File content: (after some time)

Node.js does NOT wait for the file to finish reading.

Callback Flow (Step-by-Step):

this is called non-blocking execution

  1. readFile() is called

  2. Node.js sends file reading task to the background

  3. Moves to next line → prints "End"

  4. Once file reading is done

  5. Callback function executes with the result

Problem with Callbacks

This is called callback hell

When tasks depend on each other, callbacks become messy:

readFile("file1.txt", (err, data1) => {
  readFile("file2.txt", (err, data2) => {
    readFile("file3.txt", (err, data3) => {
      console.log(data1, data2, data3);
    });
  });
});

Solution: Promises (Better Readability)

readFilePromise("file1.txt")
  .then(data1 => readFilePromise("file2.txt"))
  .then(data2 => readFilePromise("file3.txt"))
  .then(data3 => console.log(data3))
  .catch(err => console.error(err));

Cleaner and easier to follow

Callback vs Promise (Comparison)

Feature Callback Promise
Readability Poor (nested) Clean (chained)
Error Handling Difficult Easy with .catch()
Scalability Hard to manage Easier to scale

2) Callback-Based Async Execution

Callback-based async execution is a way to handle tasks in Node.js where a function (called a callback) is passed into another function and executed after the task is completed.

Real-Life Scenario :-

Imagine you ask someone to bring you a file . Instead of waiting idle, you continue doing other work. Once the file is ready, they come back and give it to you.

That “come back and inform” part is like a callback function.

Example: Callback-Based File Reading

console.log("Start");

readFile("file.txt", (err, data) => {
  if (err) {
    console.log("Error:", err);
    return;
  }
  console.log("File content:", data);
});

console.log("End");

Execution Flow :-

  1. "Start" is printed

  2. readFile() is called

  3. Node.js sends file reading task to background

  4. "End" is printed immediately

  5. Once file is ready → callback function runs

  6. File content is displayed

Output

Start
End
File content: (after some time)

The program does not wait for the file — this is called non-blocking execution

Structure of a Callback

function task(callback) {
  // do something
  callback();
}

A callback is simply a function passed as an argument.

Error-First Callback Pattern

function(err, data) {
  if (err) {
    // handle error
  } else {
    // use data
  }
}

First parameter = error 👉 Second parameter = result

Problem: Callback Hell

When multiple async tasks depend on each other:

readFile("file1.txt", (err, data1) => {
  readFile("file2.txt", (err, data2) => {
    readFile("file3.txt", (err, data3) => {
      console.log(data1, data2, data3);
    });
  });
});

Deep nesting makes code:

  • Hard to read

  • Hard to debug

  • Hard to maintain

Why Still Important?

  • Core concept of async programming

  • Used in many older APIs

  • Foundation for Promises and async/await


3) Problems with Nested Callbacks (Callback Hell)

When callbacks are placed inside other callbacks repeatedly, the code becomes deeply nested. This situation is commonly called “callback hell” and creates several practical problems.

Imagine you need to read three files one after another. Each file depends on the previous one. Using callbacks, you might write code like this:

readFile("file1.txt", (err, data1) => {
  if (err) return console.error(err);

  readFile("file2.txt", (err, data2) => {
    if (err) return console.error(err);

    readFile("file3.txt", (err, data3) => {
      if (err) return console.error(err);

      console.log(data1, data2, data3);
    });
  });
});

This deeply nested structure is called callback hell

Callback Flow (Step-by-Step)

  1. readFile("file1.txt") starts

  2. After completion → callback runs

  3. Inside it, readFile("file2.txt") starts

  4. After completion → its callback runs

  5. Inside it, readFile("file3.txt") starts

  6. Final callback prints all data

👉 Each step waits for the previous one, causing nested execution

Problems with Nested Callbacks

1) Poor Readability

  • Code becomes hard to understand

  • Too many nested levels (pyramid shape)

2) Difficult Debugging

  • Errors can occur at multiple levels

  • Hard to trace where things went wrong

3) Error Handling Issues

  • Need to handle errors in every callback

  • Easy to miss or duplicate error handling

4) Hard to Maintain

  • Adding new steps makes code more complex

  • Refactoring becomes difficult

Solution: Promises (Cleaner Approach)

readFilePromise("file1.txt")
  .then(data1 => readFilePromise("file2.txt"))
  .then(data2 => readFilePromise("file3.txt"))
  .then(data3 => console.log(data3))
  .catch(err => console.error(err));

No nesting, just a clean chain

Callback vs Promise

Feature Callback Promise
Structure Nested (pyramid) Flat (chain)
Readability Hard to read Easy to follow
Error Handling Repeated everywhere Single .catch()
Maintenance Difficult Easier

4) Promise-Based Async Handling

A Promise is an object in JavaScript that represents the result of an asynchronous operation. It can be in one of three states: pending, fulfilled, or rejected. Promises help avoid callback hell and make async code more readable and manageable.

Imagine the same file reading scenario where you need to read files one after another. Using Promises, the code becomes cleaner and easier to follow.

Example:

readFilePromise("file1.txt")
  .then(data1 => {
    console.log("File1:", data1);
    return readFilePromise("file2.txt");
  })
  .then(data2 => {
    console.log("File2:", data2);
    return readFilePromise("file3.txt");
  })
  .then(data3 => {
    console.log("File3:", data3);
  })
  .catch(err => {
    console.error("Error:", err);
  });

This structure avoids deep nesting and keeps the flow linear.

Promise Flow (Step-by-Step)

readFilePromise("file1.txt") starts After completion → first .then() runs Inside it, readFilePromise("file2.txt") starts After completion → second .then() runs Inside it, readFilePromise("file3.txt") starts After completion → final .then() runs If any error occurs → .catch() handles it

👉 Each step runs in sequence, but without nesting

Key Advantages of Promises

  1. Better Readability Code looks clean and structured

  2. Centralized Error Handling Single .catch() handles all errors

  3. Avoids Callback Hell No deeply nested functions

  4. Easier to Maintain Simple to add or modify steps

Promise States

Pending → operation not completed Fulfilled → operation successful Rejected → operation failed


5) Benefits of Promises

Promises are used to handle asynchronous operations in a cleaner and more structured way compared to callbacks.

Key Benefits

1) Better Readability
Code is written in a clear, linear flow using .then() instead of deep nesting

2) Avoids Callback Hell
No pyramid structure — promises use chaining, making code easier to manage

3) Centralized Error Handling
Errors can be handled in one place using .catch() instead of repeating checks

4) Improved Maintainability
Easier to update, extend, and refactor code

5) Chaining Support
Multiple async operations can be linked step-by-step

Example :

readFilePromise("file1.txt")
  .then(data1 => readFilePromise("file2.txt"))
  .then(data2 => readFilePromise("file3.txt"))
  .then(data3 => console.log(data3))
  .catch(err => console.error(err));

Better Control Flow Provides methods like:

  • .then() → for success

  • .catch() → for errors

  • .finally() → runs always


Final Summary

Node.js uses asynchronous code to avoid blocking while handling tasks like file reading, allowing it to run other code simultaneously (non-blocking execution). Callbacks execute after a task completes but can lead to “callback hell” when nested, making code hard to read and maintain. Promises solve this by providing a clean, chained structure with better readability and centralized error handling, making async code easier to manage.