DEV Community

Bhupesh Chandra Joshi
Bhupesh Chandra Joshi

Posted on

Async Code in Node.js: Callbacks and Promises

Async Code in Node.js: Callbacks and Promises

Node.js is built on an event-driven, non-blocking I/O model. This design choice makes it exceptionally performant for I/O-heavy applications like web servers, APIs, and real-time services. But to truly master Node.js, you must understand how asynchronous code works—starting with the classic callbacks and evolving to the much cleaner Promises.

In this comprehensive guide, we’ll explore why async code is essential, how callbacks work (and where they fail), and how Promises solve those problems with far better readability and maintainability.


1. Why Async Code Exists in Node.js

Imagine a traditional server built in a language like PHP or Python (in synchronous mode). When it needs to read a file or query a database, the entire thread blocks until the operation completes. During that wait, the server can’t handle other requests. This leads to poor scalability.

Node.js takes a different approach. It uses libuv under the hood to offload I/O operations to the operating system or thread pool. The main JavaScript thread remains free to handle other tasks while waiting for I/O.

Key takeaway: Async code in Node.js isn’t optional—it’s fundamental to its performance and scalability.

A perfect real-world example is reading files from the filesystem.


2. Callback-based Async Execution

Let’s start with a practical scenario: You want to read a config.json file, parse it, then read a second file based on a value inside the first.

Synchronous Version (Blocking – Don’t do this in production)

const fs = require('fs');

const config = JSON.parse(fs.readFileSync('config.json', 'utf8'));
const data = fs.readFileSync(config.dataFile, 'utf8');
console.log(data);
Enter fullscreen mode Exit fullscreen mode

This works for small scripts, but in a server handling thousands of requests, it would be disastrous.

Asynchronous Version with Callbacks

const fs = require('fs');

fs.readFile('config.json', 'utf8', (err, configData) => {
  if (err) {
    console.error('Error reading config:', err);
    return;
  }

  const config = JSON.parse(configData);

  fs.readFile(config.dataFile, 'utf8', (err, data) => {
    if (err) {
      console.error('Error reading data file:', err);
      return;
    }

    console.log('Data:', data);
  });
});
Enter fullscreen mode Exit fullscreen mode

Step-by-step callback flow:

  1. fs.readFile() is called.
  2. Node.js delegates the file read to the OS/thread pool.
  3. The main thread continues executing other code.
  4. When the OS finishes reading the file, it triggers an event.
  5. The callback you provided is executed with either an err or the file content.

This pattern is called continuation-passing style—you pass a function that “continues” the program once the async operation finishes.


3. Problems with Nested Callbacks (“Callback Hell”)

What happens when you have multiple dependent async operations?

fs.readFile('config.json', 'utf8', (err1, configData) => {
  if (err1) return console.error(err1);

  const config = JSON.parse(configData);

  fs.readFile(config.dataFile, 'utf8', (err2, data1) => {
    if (err2) return console.error(err2);

    fs.readFile(config.logFile, 'utf8', (err3, data2) => {
      if (err3) return console.error(err3);

      // Process everything...
      processData(data1, data2);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

This is the infamous Callback Hell or Pyramid of Doom.

Common problems:

  • Deep nesting makes code hard to read and reason about.
  • Error handling is repetitive and error-prone.
  • Variable scoping becomes messy.
  • Difficult to refactor or add new steps.
  • Control flow (loops, conditionals) with async operations is painful.

4. Promise-based Async Handling

Promises represent a value that may be available now, in the future, or never. They provide a cleaner way to handle asynchronous operations.

Converting the Example to Promises

const fs = require('fs').promises; // Modern way

async function loadData() {
  try {
    const configData = await fs.readFile('config.json', 'utf8');
    const config = JSON.parse(configData);

    const [data1, data2] = await Promise.all([
      fs.readFile(config.dataFile, 'utf8'),
      fs.readFile(config.logFile, 'utf8')
    ]);

    processData(data1, data2);
  } catch (error) {
    console.error('Error:', error);
  }
}

loadData();
Enter fullscreen mode Exit fullscreen mode

Or without async/await (using .then()):

fs.readFile('config.json', 'utf8')
  .then(configData => {
    const config = JSON.parse(configData);
    return Promise.all([
      fs.readFile(config.dataFile, 'utf8'),
      fs.readFile(config.logFile, 'utf8')
    ]).then(([data1, data2]) => ({ config, data1, data2 }));
  })
  .then(({ data1, data2 }) => {
    processData(data1, data2);
  })
  .catch(err => console.error(err));
Enter fullscreen mode Exit fullscreen mode

Promise Lifecycle:

  • Pending: Initial state, neither fulfilled nor rejected.
  • Fulfilled: Operation completed successfully (resolve()).
  • Rejected: Operation failed (reject() or unhandled error).

You can chain .then() for success and .catch() for errors. Promises also support .finally() for cleanup.


5. Benefits of Promises

Aspect Callbacks Promises
Readability Poor (nested) Excellent (chaining + async/await)
Error Handling Repetitive, manual Centralized with .catch()
Parallel Operations Very difficult Easy with Promise.all()
Control Flow Hard (loops, conditions) Natural with async/await
Debugging Stack traces are messy Much better stack traces
Maintainability Low High

Modern best practice: Use async/await (which is syntactic sugar over Promises) for most code. It makes asynchronous code look and behave almost like synchronous code while retaining all the non-blocking benefits.


Visualizing the Concepts

Callback Execution Chain (Conceptual Diagram):

Main Thread
   ↓
fs.readFile() → delegates to libuv
   ↓ (non-blocking)
Continue other work
   ↓ (when OS finishes)
Callback 1 fires → fs.readFile() inside callback
   ↓
Callback 2 fires → and so on...
Enter fullscreen mode Exit fullscreen mode

Promise Lifecycle Flow:

Pending 
  ├──► Fulfilled → .then() handlers
  └──► Rejected  → .catch() handler
Enter fullscreen mode Exit fullscreen mode

Final Tips for Modern Node.js Development

  1. Always prefer the fs.promises API or libraries that return Promises.
  2. Use async/await + try/catch for most business logic.
  3. Handle errors at the right level—don’t let unhandled promise rejections crash your app (use process-level handlers in older code).
  4. For complex flows, consider libraries like p-limit, async, or just stick with native Promise.allSettled() when needed.
  5. Understand that async/await is still asynchronous under the hood—don’t block the event loop with heavy CPU work.

Conclusion

Callbacks were the foundation of Node.js asynchronous programming and taught us valuable lessons about non-blocking I/O. However, Promises (and especially async/await) dramatically improved developer experience while preserving performance.

Mastering both gives you deep insight into how Node.js works and allows you to read and maintain codebases of any age.

What’s next?

Explore async iterators, EventEmitter, or move on to streams—the next level of powerful asynchronous patterns in Node.js.


Happy coding! If you enjoyed this post, share it with your fellow developers or leave a comment with your biggest async challenge.

Top comments (0)