DEV Community

Sakshi Tambole
Sakshi Tambole

Posted on

Async Code in Node.js: Callbacks and Promises

Asynchronous programming in Node.js prevents blocking the single main thread, allowing efficient, non-blocking I/O operations through callbacks and promises.

While callback-based approaches can lead to complex "callback hell," Promise-based handling offers better readability and streamlined error management.

Why async code exists in Node.js

Node.js is built on a non-blocking, single-threaded architecture. That means it can handle multiple operations at once—but only if those operations don’t block execution.

Imagine reading a file:

  • A blocking (sync) approach would pause everything until the file is read.
  • An async approach lets Node.js move on to other tasks while waiting.

Start with a Simple Scenario: Reading a File

  1. Read a file
  2. Process its content
  3. Print the result

Callback-based async execution

Callback-based async execution is the original way Node.js handles asynchronous tasks. Instead of waiting for an operation to finish, you pass a function (callback) that Node.js will execute later—once the task is complete.

The Core Idea

A callback is simply a function passed as an argument to another function, to be executed later.

In async programming:

  • You start a task (like reading a file)
  • You don’t wait for it to finish
  • You provide a callback that runs after completion

Example: Reading a File

const fs = require("fs");

fs.readFile("data.txt", "utf8", (err, data) => {
  if (err) {
    console.error("Error:", err);
    return;
  }

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

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

Enter fullscreen mode Exit fullscreen mode

What Actually Happens (Step-by-Step Flow)

  1. fs.readFile() is called
  2. Node.js delegates the file-reading task to the system (non-blocking)
  3. The program continues immediately to the next line → "This runs before file is read" gets printed
  4. Once the file is read: The callback function is placed in the event loop queue
  5. The callback executes:
  6. If error → err is filled
  7. If success → data contains file content
Start → readFile() → continue other work → callback executes later

Enter fullscreen mode Exit fullscreen mode

Problems with nested callbacks

When multiple asynchronous operations depend on each other, callbacks often get nested inside one another. This leads to what developers call “callback hell”—code that is hard to read, maintain, and debug.

fs.readFile("data.txt", "utf8", (err, data) => {
  if (err) return console.error(err);

  fs.writeFile("copy.txt", data, (err) => {
    if (err) return console.error(err);

    fs.readFile("copy.txt", "utf8", (err, newData) => {
      if (err) return console.error(err);

      console.log("Final Data:", newData);
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

Problems:

  1. Deep nesting (hard to read)
  2. Error handling becomes messy
  3. Hard to maintain and debug

This is often called “callback hell”.

Promise-based async handling

Promises are a modern way to handle asynchronous operations in Node.js without the mess of deeply nested callbacks. They represent a value that will be available in the future—either successfully or with an error.

The Core Idea

A Promise is an object that manages an async operation and its result.

It has 3 states:

Pending → Fulfilled → Rejected

Enter fullscreen mode Exit fullscreen mode
  • Pending → operation still running
  • Fulfilled → operation completed successfully
  • Rejected → operation failed
const fs = require("fs").promises;

fs.readFile("data.txt", "utf8")
  .then((data) => {
    console.log("File content:", data);
  })
  .catch((err) => {
    console.error("Error:", err);
  });

console.log("Runs before file is read");

Enter fullscreen mode Exit fullscreen mode

How It Works (Step-by-Step)

  1. readFile() returns a Promise
  2. Node.js starts reading the file in the background
  3. Meanwhile, other code runs immediately
  4. When the file is ready:
  • If success → .then() executes
  • If error → .catch() executes

Benefits of promises

Promises were introduced to solve many of the issues caused by callback-based async code. They make asynchronous programming cleaner, more structured, and easier to manage.

  1. Improved Readability

Promises eliminate deep nesting and give your code a linear, top-to-bottom flow.

Callback (hard to read)

task1((err, res1) => {
  if (err) return handleError(err);

  task2(res1, (err, res2) => {
    if (err) return handleError(err);

    task3(res2, (err, res3) => {
      console.log(res3);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Promise (clean)

task1()
  .then(task2)
  .then(task3)
  .then(console.log)
  .catch(handleError);
Enter fullscreen mode Exit fullscreen mode
  1. Avoids Callback Hell

Promises flatten the structure of async code.

Instead of this:

task → callback → callback → callback
Enter fullscreen mode Exit fullscreen mode

You get:

task → then → then → then
Enter fullscreen mode Exit fullscreen mode
  1. Centralized Error Handling

With callbacks, every step needs error handling.

With Promises:

.catch((err) => {
  console.error(err);
});
Enter fullscreen mode Exit fullscreen mode
  • One .catch() handles all errors in the chain
  • Cleaner and less repetitive
  • Better Control Over Flow

Promises give you more control over execution:

  • Chain operations in order
  • Return values between steps
  • Handle success and failure clearly
doSomething()
  .then((result) => result * 2)
Enter fullscreen mode Exit fullscreen mode

.then((value) => console.log(value));

  1. Easier Composition of Async Tasks

You can combine multiple Promises easily:

Promise.all([task1(), task2(), task3()])
  .then((results) => {
    console.log(results);
  });

Enter fullscreen mode Exit fullscreen mode
  • Run tasks in parallel
  • Get all results together

Other utilities:

  • Promise.race()
  • Promise.allSettled()
  1. More Predictable Behavior

Promises guarantee:

  • A result happens once
  • Either success or failure (not both) No accidental multiple calls (common in callbacks)
  1. Better Debugging
  • Cleaner stack traces
  • Clear chain of execution
  • Errors propagate automatically
  1. Works Seamlessly with Async/Await

Promises are the foundation of modern async syntax:

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

Enter fullscreen mode Exit fullscreen mode
  1. Improved Maintainability
  • Less nesting → easier to modify
  • Clear structure → easier for teams
  • Reusable functions → better modularity

Compare callback vs promise readability

Aspect Callbacks Promises
Readability Poor (nested) Clean (chained)
Error Handling Manual everywhere Centralized (catch)
Maintainability Difficult Easier

1. Callback Execution Chain

readFile()
   ↓
callback 1
   ↓
writeFile()
   ↓
callback 2
   ↓
readFile()
   ↓
callback 3

Enter fullscreen mode Exit fullscreen mode

2. Promise Lifecycle Flow

+-----------+
        | Pending   |
        +-----------+
         /       \
        /         \
Fulfilled      Rejected
   |               |
 then()          catch()

Enter fullscreen mode Exit fullscreen mode

Top comments (0)