DEV Community

Cover image for Async Code in Node.js: Callbacks and Promises
SATYA SOOTAR
SATYA SOOTAR

Posted on

Async Code in Node.js: Callbacks and Promises

Hello readers 👋, welcome to the 7th of our Node.js journey!

Last time, we explored how a single-threaded Node.js process magically handles thousands of requests at once. We saw that non-blocking I/O is the secret, and the event loop keeps everything spinning. But we left one critical question unanswered: how do we actually write the code that starts an async operation and then does something with the result?

Today, we are going to dive into the two most fundamental ways to handle asynchronous code in Node.js: callbacks and promises. We'll see how callbacks work, why they can become messy, and how promises rescue us with cleaner, more readable code.

Let's get into it.

Why async code exists in Node.js

We already know that Node.js uses a non-blocking model. When you call something like fs.readFile, you don't wait for the file to be fully loaded. Instead, you pass a function (a callback) that the event loop will run later, once the file content is ready.

This pattern allows the main thread to stay responsive. But it also means you cannot simply write:

const data = readFile("file.txt");
console.log(data);
Enter fullscreen mode Exit fullscreen mode

Because that would either block the thread (if the function is synchronous) or give you undefined (if it's asynchronous and returns immediately). You need a way to tell Node.js: "Here's what I want you to do, and here's the function to call when you're done."

That's the heart of async code: scheduling a future action.

Callback-based async execution

A callback is simply a function passed as an argument to another function. The outer function performs an operation and then, when the operation finishes, it "calls back" the inner function with the result.

In Node.js, many core modules follow a callback convention: the callback is the last argument, and the first parameter of the callback is an error object (if any), followed by the data. This is often called an error-first callback.

Let's start with a concrete file reading example:

const fs = require("fs");

console.log("Start reading file...");

fs.readFile("example.txt", "utf8", (err, data) => {
  if (err) {
    console.error("Failed to read file:", err.message);
    return;
  }
  console.log("File content:", data);
});

console.log("This runs before the file content is logged.");
Enter fullscreen mode Exit fullscreen mode

The output:

Start reading file...
This runs before the file content is logged.
File content: (the actual content)
Enter fullscreen mode Exit fullscreen mode

Step by step, here's how the callback flow works:

  1. fs.readFile is called with the filename, encoding, and a callback function. Node.js hands the file read operation to the OS (or the libuv thread pool).
  2. fs.readFile returns immediately. The main thread moves to the next line: console.log("This runs before...").
  3. Sometime later, the file read completes. The callback is placed into the event loop's task queue.
  4. When the call stack is empty, the event loop picks up the callback and executes it on the main thread.
  5. Inside the callback, we check for errors first. If there's an error, we handle it; otherwise, we use the data.

This pattern works, and it's the core of many Node.js applications. But it has a well-known downside.

Problems with nested callbacks (callback hell)

Real applications rarely do just one async operation. Often, one operation depends on the result of another. For example: read a configuration file, then connect to a database, then fetch a user by ID, then load the user's orders.

With callbacks, each step nests inside the previous one. This leads to the infamous "pyramid of doom" or "callback hell":

fs.readFile("config.json", "utf8", (err, config) => {
  if (err) handleError(err);
  else {
    connectDB(config.dbUrl, (err, db) => {
      if (err) handleError(err);
      else {
        db.findUser("satya", (err, user) => {
          if (err) handleError(err);
          else {
            user.loadOrders((err, orders) => {
              if (err) handleError(err);
              else {
                console.log(orders);
              }
            });
          }
        });
      }
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

The problems are clear:

  • The code indents deeper and deeper, making it hard to read.
  • Error handling is repetitive and scattered across each callback.
  • Adding another step to the chain means adding another level of indentation.
  • The flow of logic is buried under syntax noise.

This style of code is brittle and difficult to maintain. It's especially painful when you need to coordinate multiple independent async operations.

Promise-based async handling

Promises were introduced to flatten this pyramid. A promise is an object that represents the eventual result of an asynchronous operation. It can be in one of three states: pending, fulfilled, or rejected. Once settled, it never changes.

Node.js core modules now provide promise-based alternatives. For the file system, you can use fs.promises (added in Node.js 10). For older modules, you can use util.promisify to convert callback-based functions into promise-returning ones.

Let's rewrite the file reading example using promises:

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

console.log("Start reading file...");

fs.readFile("example.txt", "utf8")
  .then((data) => {
    console.log("File content:", data);
  })
  .catch((err) => {
    console.error("Failed to read file:", err.message);
  });

console.log("This runs before the file content is logged.");
Enter fullscreen mode Exit fullscreen mode

The output is identical in order, but the structure is different. We call fs.readFile, which returns a promise. We then attach a .then() for the success case and a .catch() for errors. The promise handles the scheduling internally; we just react to the result.

How promises improve the chain

The real power becomes evident when we have multiple sequential operations. Because .then() itself always returns a new promise, we can chain further .then() calls without nesting. Error handling with .catch() works for the entire chain.

Let's reimagine the nested callback scenario with promises:

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

function connectDB(config) { /* returns a promise */ }
function findUser(db, name) { /* returns a promise */ }
function loadOrders(user) { /* returns a promise */ }

fs.readFile("config.json", "utf8")
  .then(config => connectDB(JSON.parse(config).dbUrl))
  .then(db => findUser(db, "satya"))
  .then(user => loadOrders(user))
  .then(orders => console.log(orders))
  .catch(err => console.error("Something failed:", err.message));
Enter fullscreen mode Exit fullscreen mode

Notice the difference:

  • Every operation is at the same indentation level.
  • The code reads top to bottom, matching the natural sequence.
  • A single .catch() at the end handles errors from any step in the chain.
  • You can easily insert a new step by adding another .then() without altering existing logic.

This flat, readable flow is the primary reason promises became the standard for async code.

Benefits of promises beyond readability

Promises offer more than just a clean syntax:

  • Reliable error propagation: If any .then() throws an error or returns a rejected promise, the error cascades down to the nearest .catch(). No more forgotten error checks in every callback.
  • Composability: You can run multiple promises in parallel using Promise.all, race them with Promise.race, or wait for all settled with Promise.allSettled. This is cumbersome with raw callbacks.
  • Return a value and move on: In a .then(), you can return a plain value (which becomes the resolved value of the next promise) or return another promise (the chain will wait for it). This allows dynamic decision making without extra nesting.
  • Unified interface: Any async operation can be wrapped in a promise, providing a consistent .then/.catch pattern across different modules and libraries.

Visualizing the difference: callback vs promise flow

Callback chain (conceptual):

readFile
 └─ callback(err, config)
      └─ connectDB(config)
           └─ callback(err, db)
                └─ findUser(db, name)
                     └─ callback(err, user)
                          └─ loadOrders(user)
                               └─ callback(err, orders)
Enter fullscreen mode Exit fullscreen mode

Each step is physically nested inside the previous one.

Promise chain:

readFile
  .then(config) -> connectDB
                  .then(db) -> findUser
                                .then(user) -> loadOrders
                                                .then(orders) -> console.log
                                                .catch(handleError)
Enter fullscreen mode Exit fullscreen mode

Each step is a link in a linear chain, with a single error exit point.

A gentle note: what about async/await?

You might have heard about async/await, which makes asynchronous code look even more synchronous. In our next post, we'll explore how async/await is just syntactic sugar over promises, and how it takes readability to an even higher level. For now, mastering promises is essential because async/await relies on them completely.

Conclusion

Asynchronous code in Node.js is unavoidable, but it doesn't have to be painful. Callbacks are the low-level foundation, but they can lead to messy, deeply nested code. Promises provide a clean, chainable, and more manageable way to handle asynchronous operations.

Let's recap what we covered:

  • Async code exists because Node.js performs non-blocking I/O, and we need a way to handle the results after they arrive.
  • Callbacks are functions passed to async operations and invoked upon completion, following an error-first convention.
  • Nested callbacks create the pyramid of doom, making code hard to read and maintain, with repetitive error handling.
  • Promises represent a future value and offer a flat .then/.catch pattern that drastically improves readability and simplifies error handling.
  • Promise chaining allows sequential async steps without nesting, and promises can be composed with Promise.all and others.
  • Understanding promises is the gateway to modern Node.js asynchronous patterns, including async/await.

With promises in your toolkit, you're ready to write cleaner, more robust Node.js code. Next time, we'll take the final step and see how async/await makes async code feel like synchronous code while keeping all the non-blocking benefits.


Hope you found this helpful! If you spot any mistakes or have suggestions, let me know. You can find me on LinkedIn and X, where I post more about web development.

Top comments (0)