Skip to main content

Command Palette

Search for a command to run...

From Callback Hell to Promises: Understanding Async Code in Node.js

Updated
โ€ข8 min read

So I've been learning Node.js for a while now, and if there's one concept that confused me the most in the beginning, it was asynchronous code. I kept asking myself โ€” why can't JavaScript just wait for things to finish before moving on?

In this article, I'll explain everything from scratch โ€” why async code exists, how callbacks work, why they become a nightmare, and how Promises ride in to save the day. I'll keep things simple and practical, so let's get into it.


๐Ÿค” Why Does Async Code Even Exist?

Let's start with a real-world scenario.

Imagine you ask Node.js to read a file from your disk. Reading a file takes time โ€” maybe a few milliseconds, maybe more if the file is large. Now, JavaScript (and Node.js) runs on a single thread, meaning it can only do one thing at a time.

If Node.js just stopped and waited every time it read a file, your entire server would freeze for that duration. Nobody else could make requests. The app would feel completely dead.

That's the problem async code solves.

Async code lets Node.js say: "Hey, go read that file. I'll keep doing other stuff. Call me back when you're done."

This is why Node.js is great for I/O-heavy tasks like reading files, making database calls, or hitting APIs โ€” it never just sits there waiting. It keeps moving.


๐Ÿ“‚ The File Reading Scenario

Let's use file reading as our example throughout this article. It's the most relatable async operation when learning Node.js.

Here's what we want to do:

  1. Read a file called user.txt
  2. Use the contents of that file to fetch user data
  3. Save the result to another file called result.txt Simple goal. But as you'll see, how we write this code matters a lot.

๐Ÿ“ž Callback-Based Async Execution

Before Promises existed, Node.js handled async operations using callbacks.

A callback is just a function you pass to another function, telling it: "When you're done, call this function."

Here's how reading a file looks with a callback:

const fs = require('fs');
 
fs.readFile('user.txt', 'utf8', function(err, data) {
  if (err) {
    console.log('Error reading file:', err);
    return;
  }
  console.log('File contents:', data);
});
 
console.log('This runs first!');

Let's break this down step by step:

  1. fs.readFile() is called โ€” Node.js kicks off the file reading operation.
  2. Node.js doesn't wait for it. It immediately moves to the next line.
  3. So "This runs first!" gets printed before the file is even read.
  4. Once the file reading is done, Node.js calls our callback function with two arguments:
    • err โ€” if something went wrong
    • data โ€” the actual file contents This is the classic error-first callback pattern in Node.js. The first argument is always the error.

This flow is called the Event Loop in action โ€” Node.js registers the callback, goes back to doing other work, and comes back to execute the callback when the operation completes.

Callbacks work. But things get ugly fast.


๐Ÿ˜ฑ The Problem: Callback Hell

Now let's go back to our goal โ€” read a file, fetch user data based on it, then save the result.

With callbacks, this is how it looks:

const fs = require('fs');
 
fs.readFile('user.txt', 'utf8', function(err, userId) {
  if (err) {
    console.log('Error reading user file:', err);
    return;
  }
 
  // Now fetch user data using the userId
  fetchUserData(userId, function(err, userData) {
    if (err) {
      console.log('Error fetching user data:', err);
      return;
    }
 
    // Now save the result
    fs.writeFile('result.txt', userData, function(err) {
      if (err) {
        console.log('Error writing result:', err);
        return;
      }
 
      console.log('Done! Result saved successfully.');
    });
  });
});

Look at that indentation. Every new async step forces you to go one level deeper into the nesting.

This pattern has a famous name in the JavaScript world:

๐Ÿ˜ˆ Callback Hell (also called the "Pyramid of Doom")

Why is this a problem?

Problem What it means
Hard to read Your eyes have to track multiple levels of nesting
Hard to debug Finding where an error came from is painful
Hard to maintain Adding a new step means touching deeply nested code
Error handling is repetitive You write if (err) return at every single level

Imagine 6-7 async steps chained like this. Your code becomes a triangle pointing to the right โ€” practically unreadable.

There had to be a better way. And there was.


๐Ÿค Promise-Based Async Handling

Introduced in ES6 (2015), Promises gave us a cleaner way to handle async operations.

A Promise is an object that represents the eventual result of an async operation. It can be in one of three states:

  • Pending โ€” the operation is still running
  • Fulfilled โ€” the operation completed successfully
  • Rejected โ€” the operation failed Here's the same file reading, but now using Promises:
const fs = require('fs').promises;
 
fs.readFile('user.txt', 'utf8')
  .then(function(userId) {
    return fetchUserData(userId); // returns a Promise
  })
  .then(function(userData) {
    return fs.writeFile('result.txt', userData);
  })
  .then(function() {
    console.log('Done! Result saved successfully.');
  })
  .catch(function(err) {
    console.log('Something went wrong:', err);
  });

That's it. Flat. Clean. Readable.

How does .then() and .catch() work?

  • .then() runs when the Promise resolves (succeeds). Whatever you return from a .then() becomes the input for the next .then().
  • .catch() runs when any Promise in the chain rejects (fails). One single error handler for the entire chain. This chaining is possible because .then() always returns a new Promise, which is why you can keep chaining them.

โœจ Creating Your Own Promise

If you're wrapping an older callback-based function into a Promise yourself, here's how you do it:

function readFilePromise(filename) {
  return new Promise(function(resolve, reject) {
    fs.readFile(filename, 'utf8', function(err, data) {
      if (err) {
        reject(err); // Something went wrong
      } else {
        resolve(data); // All good, pass the data
      }
    });
  });
}
 
// Now you can use it like:
readFilePromise('user.txt')
  .then(data => console.log('File data:', data))
  .catch(err => console.log('Error:', err));

The new Promise() constructor takes a function with two arguments:

  • resolve โ€” call this when the operation succeeds
  • reject โ€” call this when the operation fails

โšก Bonus: async/await (Promises, but Even Cleaner)

Once you understand Promises, async/await is just syntactic sugar on top of them. It makes your async code look like normal synchronous code:

const fs = require('fs').promises;
 
async function processUser() {
  try {
    const userId = await fs.readFile('user.txt', 'utf8');
    const userData = await fetchUserData(userId);
    await fs.writeFile('result.txt', userData);
    console.log('Done! Result saved successfully.');
  } catch (err) {
    console.log('Something went wrong:', err);
  }
}
 
processUser();

await simply pauses execution inside an async function until the Promise resolves. Under the hood, it's still using Promises โ€” just written in a way that's even easier to read.


๐Ÿ“Š Callback vs Promise: Side-by-Side Comparison

Let's put both approaches side by side so the difference is crystal clear:

Callback approach:

fs.readFile('user.txt', 'utf8', function(err, userId) {
  if (err) return handleError(err);
  fetchUserData(userId, function(err, userData) {
    if (err) return handleError(err);
    fs.writeFile('result.txt', userData, function(err) {
      if (err) return handleError(err);
      console.log('Done!');
    });
  });
});

Promise approach:

fs.readFile('user.txt', 'utf8')
  .then(userId => fetchUserData(userId))
  .then(userData => fs.writeFile('result.txt', userData))
  .then(() => console.log('Done!'))
  .catch(handleError);

Same logic. Completely different readability.


๐Ÿ† Benefits of Promises (Summary)

Feature Callbacks Promises
Readability Nested, hard to follow Flat chain, easy to read
Error Handling if (err) at every level Single .catch() for all
Chaining Ugly nesting Clean .then() chain
Composability Difficult Promise.all(), Promise.race() etc.
Debugging Hard to trace Stack traces are cleaner

๐ŸŽฏ Wrapping Up

Here's the big picture:

  • Node.js is single-threaded, so it uses async code to avoid blocking.
  • Callbacks were the original way to handle async โ€” they work, but nest deeply and become hard to manage.
  • Promises solve this with a clean .then()/.catch() chain โ€” one error handler, no nesting.
  • async/await is the modern, cleanest way to write Promise-based code. If you're just getting started with Node.js, my suggestion is: understand callbacks first (so you know why Promises exist), then move to Promises, and eventually make async/await your default.

The journey from callback hell to clean async code is one of the most satisfying things about growing as a Node.js developer. Trust me on that one.