Skip to main content

Command Palette

Search for a command to run...

Async Code in Node.js: Callbacks and Promises

Published
โ€ข4 min read
Async Code in Node.js: Callbacks and Promises

This guide explores the transition from traditional callback patterns to modern Promise-based execution in Node.js, highlighting why asynchronous patterns are the heart of the platform.


1. Why Async Code Exists in Node.js

Node.js is built on a single-threaded event loop. If a task is blocking (like reading a large file from disk or waiting for a slow network request), the entire application stops.

Asynchronous code allows Node.js to:

  • Delegate Tasks: Hand off I/O operations to the system kernel or the Libuv worker pool (used for file system and some network tasks).

  • Stay Responsive: Keep the main thread free to handle new incoming user requests.

  • Concurrency: Manage thousands of active connections simultaneously without needing multiple CPU threads.


2. Callback-Based Async Execution

A callback is a function passed as an argument to another function, which is invoked once the background task is complete.

Example: File Reading

const fs = require('fs');

console.log("1. Application Start");

// fs.readFile is an asynchronous function
fs.readFile('user_data.txt', 'utf8', (err, data) => {
  // This function is the "Callback"
  if (err) {
    console.error("3. Error found:", err.message);
    return;
  }
  console.log("3. File Content:", data);
});

console.log("2. Moving to other tasks...");

Callback Flow (Step-by-Step)

  1. Initiation: The code calls fs.readFile.

  2. Offloading: Node.js sends the request to the OS or Libuv worker pool and registers the callback.

  3. Continuation: The main thread immediately moves to the next line.

  4. Task Completion: The OS completes the task and places the callback in the Callback Queue.

  5. Event Loop: The Event Loop executes the callback after the main script finishes.


3. Problems with Nested Callbacks

When multiple asynchronous operations depend on each other, you enter Callback Hell (also known as the Pyramid of Doom).

fs.readFile('config.json', (err, cfg) => {
  getDatabase(cfg.dbUrl, (err, db) => {
    getUser(db, cfg.userId, (err, user) => {
      getProfile(user.id, (err, profile) => {
        // Code becomes hard to read and maintain
      });
    });
  });
});

Issues

  • Readability: Logic flows horizontally instead of vertically.

  • Error Handling: Requires repeated if (err) checks at every level.

  • Fragility: Difficult to refactor and debug.


4. Promise-Based Async Handling

A Promise is an object representing the eventual completion (or failure) of an asynchronous operation. It enables cleaner and more structured code.


Readability Comparison

Callback Style

doA((err, resA) => {
  doB(resA, (err, resB) => {
    console.log(resB);
  });
});

Promise Style

doA()
  .then(resA => doB(resA))
  .then(resB => console.log(resB))
  .catch(err => console.error(err));

5. Benefits of Promises

  • Flattened Structure: Code reads like a sequence of steps.

  • Centralized Error Handling: A single .catch() handles errors across the chain.

  • Predictability: Promises have defined states:

    • Pending

    • Fulfilled

    • Rejected

  • Composition: Promise.all() allows parallel execution of multiple async tasks.


6. Event Loop & Execution Priority

Important Concept

  • Callbacks are placed in the Callback Queue (Macrotask Queue).

  • Promises use the Microtask Queue.

๐Ÿ‘‰ The Microtask Queue is processed before the Callback Queue, which is why Promise handlers (.then, .catch) execute earlier than callbacks.


7. Visualization

Callback Execution Flow

Main Script โ†’ fs.readFile() โ†’ Main Script Continues โ†’ I/O Completes โ†’ Callback Queue โ†’ Callback Runs

Promise Lifecycle

  • Pending: Initial state (operation in progress)

  • Fulfilled: Operation successful โ†’ triggers .then()

  • Rejected: Operation failed โ†’ triggers .catch()


Conclusion

Callbacks introduced asynchronous programming in Node.js but can become complex and hard to manage in real-world applications.

Promises improve this by offering:

  • Cleaner and more readable syntax

  • Better error handling

  • More predictable execution flow

Modern Node.js development often builds on Promises using async/await, making asynchronous code look synchronous and even easier to understand.