Skip to main content

Command Palette

Search for a command to run...

Async Code in Node.js: Callbacks and Promises

Updated
β€’11 min read

1. Why async code exists in Node.js

πŸ“Œ Introduction

Node.js is built to handle high-performance, scalable applications, especially servers that deal with thousands of users at the same time. To achieve this, it relies heavily on asynchronous (async) code.

But the real question is β€” why does async code even exist in Node.js?


🧠 Core Reason

Async code exists because Node.js is single-threaded.

It has only one main thread (event loop) to handle all operations.

If Node.js used only synchronous (blocking) code:

  • One task would block the entire server

  • Other users would have to wait ❌

  • Performance would drop drastically

πŸ‘‰ Async code solves this problem.


βš™οΈ The Problem Without Async Code

Let’s say 3 users send requests:

  1. User A β†’ File read (takes 2 sec)

  2. User B β†’ API call

  3. User C β†’ Database query

πŸ‘‰ If everything is blocking:

  • User B & C must wait for A ❌

  • Server becomes slow


⚑ How Async Code Solves It

With async code:

  • File read runs in background

  • Node.js moves to next request immediately βœ…

  • All users are handled efficiently

πŸ‘‰ This makes Node.js:

  • Fast

  • Scalable

  • Non-blocking


🧍 Real-World Analogy

Think of a call center πŸ“ž:

  • ❌ Blocking: One agent handles one call at a time β†’ others wait

  • βœ… Async: Calls are queued + routed efficiently β†’ multiple handled

πŸ‘‰ Async system = better performance


πŸ’» Code Comparison

❌ Blocking (Synchronous)

const fs = require('fs');

const data = fs.readFileSync('file.txt', 'utf-8');
console.log(data);

πŸ‘‰ Everything waits until file is read


βœ… Non-Blocking (Async)

const fs = require('fs');

fs.readFile('file.txt', 'utf-8', (err, data) => {
    console.log(data);
});

πŸ‘‰ Code continues executing without waiting


πŸš€ Why Async Code is Essential

  • βœ… Handles multiple users simultaneously

  • βœ… Prevents server blocking

  • βœ… Improves response time

  • βœ… Enables real-time applications (chat apps, APIs, streaming)


πŸ”‘ Key Takeaway

  • Async code exists because Node.js is single-threaded

  • It prevents blocking of the event loop

  • It allows parallel handling of I/O tasks

  • This is what makes Node.js fast & scalable

2. Callback-based async execution

πŸ“Œ Introduction

In the early days of Node.js, callbacks were the primary way to handle asynchronous operations. They form the foundation of async programming and are still widely used under the hood.


🧠 What is Callback-Based Async Execution?

A callback is simply a function that is passed as an argument to another function, and it gets executed after the async task is completed.

Instead of waiting, Node.js says: β€œWhen the task is done, call this function.”


βš™οΈ How It Works

  1. You start an async task (e.g., file read)

  2. Provide a callback function

  3. Node.js delegates the task to background (libuv)

  4. Moves ahead without waiting βœ…

  5. Once task is done β†’ callback is executed


🧍 Real-World Analogy

Think of ordering coffee β˜•:

  • You place your order

  • Give your name (callback)

  • Go do other work

  • When coffee is ready β†’ they call your name

πŸ‘‰ Callback = β€œnotification function” when task is done


πŸ’» Example: File Read with Callback

const fs = require('fs');

console.log("Start");

fs.readFile('file.txt', 'utf-8', (err, data) => {
    if (err) {
        console.error("Error:", err);
        return;
    }
    console.log("File Content:", data);
});

console.log("End");

πŸ” Execution Flow

  • "Start" prints

  • File reading starts in background

  • "End" prints immediately βœ…

  • After completion β†’ callback runs and prints data

πŸ‘‰ Output:

Start
End
File Content: ...

⚠️ Important Pattern: Error-First Callback

Node.js follows a standard pattern:

(err, data) => { ... }
  • err β†’ contains error (if any)

  • data β†’ contains result

πŸ‘‰ Always handle error first


⚠️ Problem with Callbacks

While callbacks work well, they can create problems:

  • ❌ Callback Hell (nested callbacks)

  • ❌ Hard to read and maintain

  • ❌ Difficult error handling

πŸ‘‰ Example (bad practice):

fs.readFile('file1.txt', 'utf-8', (err, data1) => {
    fs.readFile('file2.txt', 'utf-8', (err, data2) => {
        fs.readFile('file3.txt', 'utf-8', (err, data3) => {
            console.log(data1, data2, data3);
        });
    });
});

πŸ”‘ Key Takeaway

  • Callbacks = functions executed after async task completes

  • Core building block of async in Node.js

  • Follow error-first pattern

  • Can lead to callback hell if overused

3. Problems with nested callbacks

πŸ“Œ Introduction

While callbacks enable asynchronous execution, things get messy when multiple async operations depend on each other. This leads to deeply nested callbacks, commonly known as Callback Hell.


🧠 What is Callback Hell?

Callback Hell happens when callbacks are nested inside other callbacks multiple levels deep, making the code:

  • Hard to read ❌

  • Hard to debug ❌

  • Hard to maintain ❌

It creates a β€œpyramid structure” that grows sideways and downward.


πŸ’» Example of Nested Callbacks

const fs = require('fs');

fs.readFile('file1.txt', 'utf-8', (err, data1) => {
    if (err) throw err;

    fs.readFile('file2.txt', 'utf-8', (err, data2) => {
        if (err) throw err;

        fs.readFile('file3.txt', 'utf-8', (err, data3) => {
            if (err) throw err;

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

πŸ‘‰ Notice how the code keeps going deeper and deeper.


⚠️ Major Problems with Nested Callbacks

1. 😡 Poor Readability

  • Code becomes difficult to understand

  • Hard to follow execution flow


2. 🧩 Difficult Maintenance

  • Adding or modifying logic is confusing

  • Small changes can break multiple layers


3. 🐞 Error Handling Becomes Messy

  • Each level needs separate error handling

  • Easy to miss edge cases


4. πŸ” Code Duplication

  • Repeated patterns (like if (err) throw err)

  • Violates clean coding principles


5. 🧠 Mental Overload

  • Developer has to track multiple nested scopes

  • Increases chances of bugs


🧍 Real-World Analogy

Imagine a process like this:

  • First, you go to office 🏒

    • Then inside, go to manager

      • Then inside, go to HR

        • Then inside, go to finance

πŸ‘‰ Each step depends on the previous one β†’ too many layers = confusing flow


🚨 Why It’s a Big Problem

In real applications:

  • APIs call APIs

  • DB calls depend on previous results

  • File operations are chained

πŸ‘‰ Nested callbacks make large-scale apps unmanageable


πŸ’‘ Solution Preview

To solve this problem, Node.js introduced:

  • βœ… Promises

  • βœ… Async/Await

πŸ‘‰ These flatten the structure and make code clean


πŸ”‘ Key Takeaway

  • Nested callbacks lead to Callback Hell

  • Makes code:

    • ❌ Unreadable

    • ❌ Hard to maintain

    • ❌ Error-prone

  • This problem led to the introduction of Promises

4. Promise-based async handling

πŸ“Œ Introduction

To overcome the problems of callback hell, JavaScript introduced Promises. They provide a clean and structured way to handle asynchronous operations without deeply nested code.


🧠 What is a Promise?

A Promise is an object that represents the eventual completion (or failure) of an asynchronous operation.

It acts like a β€œplaceholder” for a value that will be available in the future.


πŸ”„ Promise States

A Promise has 3 states:

  • ⏳ Pending β†’ Initial state (operation not completed)

  • βœ… Fulfilled β†’ Operation completed successfully

  • ❌ Rejected β†’ Operation failed


βš™οΈ How Promises Work

  1. Async task starts

  2. Promise is returned immediately

  3. You attach handlers:

    • .then() β†’ for success

    • .catch() β†’ for errors

  4. When task completes β†’ corresponding handler runs


🧍 Real-World Analogy

Think of ordering something online πŸ“¦:

  • You place an order β†’ Pending

  • Item delivered β†’ Fulfilled βœ…

  • Order canceled β†’ Rejected ❌

πŸ‘‰ Promise = tracking system for future result


πŸ’» Example: Promise-Based File Read

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

console.log("Start");

fs.readFile('file.txt', 'utf-8')
  .then(data => {
      console.log("File Content:", data);
  })
  .catch(err => {
      console.error("Error:", err);
  });

console.log("End");

πŸ” Execution Flow

  • "Start" prints

  • File reading starts

  • "End" prints immediately βœ…

  • After completion β†’ .then() executes


⚑ Promise Chaining (Solving Callback Hell)

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

fs.readFile('file1.txt', 'utf-8')
  .then(data1 => {
      console.log(data1);
      return fs.readFile('file2.txt', 'utf-8');
  })
  .then(data2 => {
      console.log(data2);
      return fs.readFile('file3.txt', 'utf-8');
  })
  .then(data3 => {
      console.log(data3);
  })
  .catch(err => console.error(err));

πŸ‘‰ No nesting β†’ clean linear flow βœ…


⚑ Why Promises are Better

  • βœ… Avoid callback hell

  • βœ… Improve readability

  • βœ… Better error handling (single .catch())

  • βœ… Easier to chain multiple async tasks


⚠️ Small Limitation

  • Still slightly complex when chaining many operations

  • That’s why async/await (next level) is preferred today


πŸ”‘ Key Takeaway

  • Promises = future value of async operation

  • Use .then() for success, .catch() for errors

  • Eliminates nested callbacks

  • Makes async code clean & manageable

5. Benefits of promises

πŸ“Œ Introduction

Promises were introduced to solve the limitations of callbacks and bring structure, readability, and reliability to asynchronous code. They are now a standard way to handle async operations in Node.js.


🧠 Why Promises are Important

Callbacks made async possible, but they created issues like callback hell and messy error handling.

Promises provide a cleaner and more manageable way to deal with async operations.


⚑ Key Benefits of Promises

1. βœ… Improved Readability

Promises make code look linear and clean, instead of deeply nested.

❌ Callback Style:

doTask1(() => {
    doTask2(() => {
        doTask3(() => {
            console.log("Done");
        });
    });
});

βœ… Promise Style:

doTask1()
  .then(() => doTask2())
  .then(() => doTask3())
  .then(() => console.log("Done"));

πŸ‘‰ Much easier to understand


2. πŸ”— Easy Chaining

Promises allow you to chain multiple async operations step by step.

  • Each .then() passes result to the next

  • No nesting required

πŸ‘‰ Clean flow of execution


3. πŸ›‘οΈ Better Error Handling

With callbacks:

  • You need error handling at every level ❌

With promises:

  • A single .catch() can handle all errors βœ…
doTask()
  .then(result => nextTask(result))
  .catch(err => console.error(err));

πŸ‘‰ Centralized error handling = cleaner code


4. πŸ”„ Avoids Callback Hell

Promises eliminate deep nesting by:

  • Flattening the structure

  • Making flow easier to follow

πŸ‘‰ No more β€œpyramid of doom” πŸ”₯



5. βš™οΈ Works Well with Async/Await

Promises are the foundation of async/await, which makes code look synchronous.

async function run() {
    try {
        const data = await fetchData();
        console.log(data);
    } catch (err) {
        console.error(err);
    }
}

πŸ‘‰ Even cleaner and more readable


6. πŸš€ Better Code Maintainability

  • Easy to debug

  • Easy to modify

  • Scales well in large applications

πŸ‘‰ Ideal for real-world backend systems


🧍 Real-World Analogy

Think of a task pipeline 🏭:

  • Step 1 β†’ Step 2 β†’ Step 3

  • Each step depends on previous

  • If something fails β†’ stop process

πŸ‘‰ Promises manage this flow smoothly


πŸ”‘ Key Takeaway

  • Promises make async code:

    • βœ… Cleaner

    • βœ… More readable

    • βœ… Easier to maintain

  • They solve:

    • ❌ Callback hell

    • ❌ Messy error handling

  • They are the base for modern async (async/await)

Diagram

  1. Callback execution chain

  2. Promise lifecycle flow