Skip to main content

Command Palette

Search for a command to run...

From Callback Hell to Promise Land: Async JavaScript in Node.js

Published
9 min read
From Callback Hell to Promise Land: Async JavaScript in Node.js

Asynchronous code is one of the first things that trips up developers coming to Node.js. You write what looks like a perfectly normal function call, and somehow the result isn't there when you expect it. You restructure things, add some nesting, and suddenly your code looks like a staircase going off the screen.

This article walks through why async exists in Node.js, how callbacks work under the hood, why they become a problem at scale, and how Promises solve those problems cleanly.


Why Async Code Exists in Node.js

Node.js runs on a single thread. There is no thread pool doing work in parallel by default — it's one thread, running one thing at a time.

So how does Node.js handle thousands of concurrent requests without blocking?

The answer is the event loop combined with non-blocking I/O. When Node.js performs an operation that takes time — reading a file, querying a database, making an HTTP request — it hands that work off to the operating system and moves on. When the OS finishes, it puts the result in a queue. The event loop picks that up and runs your callback.

┌────────────────────────────────────────┐
│              Your Code                 │
│                                        │
│  fs.readFile("data.txt", callback) ──► │──► OS handles file read
│                                        │         │
│  ...continues executing...             │         ▼
│                                        │   [Event Loop Queue]
│  ◄──────────────────────────────────── │◄── callback(data) fires
└────────────────────────────────────────┘

This is why you cannot do this:

let content;
fs.readFile("data.txt", "utf8", (err, data) => {
  content = data;
});

console.log(content); // undefined — the file hasn't been read yet

The readFile call is non-blocking. Execution moves past it immediately. The file content arrives later, on the event loop, when the OS is done.

This is the foundation everything else builds on.


Callback-Based Async Execution

A callback is just a function you pass to another function, to be called when an async operation is done.

Here is the simplest possible example:

const fs = require("fs");

fs.readFile("user.txt", "utf8", function (err, data) {
  if (err) {
    console.error("Failed to read file:", err.message);
    return;
  }
  console.log("File contents:", data);
});

Step-by-step: What actually happens

1. fs.readFile() is called
   └─ Node registers the file read with the OS
   └─ Execution continues immediately (non-blocking)

2. Event loop keeps spinning
   └─ Other code can run while file is being read

3. OS completes the file read
   └─ Result is pushed to the event loop queue

4. Event loop picks it up
   └─ Your callback is called with (err, data)

5. Inside the callback:
   └─ err is null if successful
   └─ data contains the file contents

The error-first callback convention (also called Node.js callback style) is the standard pattern. The first argument is always an error object (or null if no error), and the second is the result.

function doSomethingAsync(input, callback) {
  setTimeout(() => {
    if (!input) {
      return callback(new Error("Input is required"));
    }
    callback(null, `Processed: ${input}`);
  }, 100);
}

doSomethingAsync("hello", (err, result) => {
  if (err) {
    console.error(err.message);
    return;
  }
  console.log(result); // "Processed: hello"
});

This works fine for a single async operation. The trouble starts when you need to chain them.


Problems with Nested Callbacks

Imagine this scenario: you need to read a user's config file, use the user ID from that file to fetch their profile from a database, and then log the activity.

Each step depends on the previous one. With callbacks, that looks like this:

const fs = require("fs");
const db = require("./db");
const logger = require("./logger");

fs.readFile("config.json", "utf8", (err, configData) => {
  if (err) {
    console.error("Could not read config:", err.message);
    return;
  }

  const config = JSON.parse(configData);

  db.getUser(config.userId, (err, user) => {
    if (err) {
      console.error("Could not fetch user:", err.message);
      return;
    }

    db.getUserPosts(user.id, (err, posts) => {
      if (err) {
        console.error("Could not fetch posts:", err.message);
        return;
      }

      logger.log(user.id, "fetched posts", (err) => {
        if (err) {
          console.error("Could not log activity:", err.message);
          return;
        }

        console.log(`\({user.name} has \){posts.length} posts`);
      });
    });
  });
});

This is callback hell — sometimes called the "pyramid of doom" because of how the indentation keeps growing to the right.

The specific problems this causes

1. Error handling is repetitive and easy to miss

Every single callback needs its own error check. If you forget one — or handle it inconsistently — errors silently disappear or crash the process in unexpected ways.

2. The flow is hard to follow

Reading this code top to bottom doesn't tell you the sequence clearly. You have to trace the nesting to understand what runs when.

3. You can't use try/catch

// This does NOT work for async callbacks
try {
  fs.readFile("data.txt", "utf8", (err, data) => {
    throw new Error("something went wrong");
  });
} catch (e) {
  console.error(e); // never runs
}

The throw inside the callback happens on a future tick of the event loop — the try/catch is long gone by then.

4. Returning early doesn't stop anything

fs.readFile("data.txt", "utf8", (err, data) => {
  if (err) return; // stops this callback, but async work is already in flight
  // ...
});

5. Reusing steps is awkward

If you want to share step 2 (fetch user) in multiple places, you end up passing deeply nested callbacks around, which only makes things worse.


Promise-Based Async Handling

A Promise is an object that represents the eventual result of an async operation. It has three states:

┌─────────────────────────────────────────────┐
│                  PROMISE                    │
│                                             │
│   PENDING ──► FULFILLED (resolved value)   │
│          └──► REJECTED  (error reason)      │
│                                             │
│  Once settled (fulfilled or rejected),      │
│  a Promise never changes state.             │
└─────────────────────────────────────────────┘

Here is the same file read operation using a Promise:

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

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

Chaining: the real power

Let's rewrite the callback hell example using Promises. Assume each function returns a Promise:

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

fs.readFile("config.json", "utf8")
  .then((configData) => {
    const config = JSON.parse(configData);
    return db.getUser(config.userId); // return the next Promise
  })
  .then((user) => {
    return db.getUserPosts(user.id).then((posts) => ({ user, posts }));
  })
  .then(({ user, posts }) => {
    return logger.log(user.id, "fetched posts").then(() => ({ user, posts }));
  })
  .then(({ user, posts }) => {
    console.log(`\({user.name} has \){posts.length} posts`);
  })
  .catch((err) => {
    // one handler catches errors from any step above
    console.error("Something went wrong:", err.message);
  });

One .catch() at the end handles errors from any step. The chain reads top to bottom. Each .then() receives the resolved value of the previous step.

Creating your own Promises

If you're wrapping a callback-based API, you create Promises manually:

function readFilePromise(path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, "utf8", (err, data) => {
      if (err) {
        reject(err); // triggers .catch()
      } else {
        resolve(data); // triggers .then()
      }
    });
  });
}

readFilePromise("config.json")
  .then((data) => console.log(data))
  .catch((err) => console.error(err.message));

async/await: cleaner Promise syntax

async/await is syntax sugar over Promises. Under the hood, it's exactly the same — Promises all the way down. But it reads like synchronous code:

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

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

    const user = await db.getUser(config.userId);
    const posts = await db.getUserPosts(user.id);

    await logger.log(user.id, "fetched posts");

    console.log(`\({user.name} has \){posts.length} posts`);
  } catch (err) {
    console.error("Something went wrong:", err.message);
  }
}

loadUserData();

Every await pauses execution inside the async function until the Promise resolves. The try/catch works exactly as expected.

Running Promises in parallel

When operations don't depend on each other, you can run them at the same time:

async function loadDashboard(userId) {
  // These three requests fire simultaneously
  const [profile, posts, notifications] = await Promise.all([
    db.getUser(userId),
    db.getUserPosts(userId),
    db.getNotifications(userId),
  ]);

  return { profile, posts, notifications };
}

With callbacks, parallelism like this requires tracking completion manually with counters or external state. With Promise.all, it's one line.


Benefits of Promises

Here is a direct comparison of the same scenario:

Callback version:

getUser(id, (err, user) => {
  if (err) return handleError(err);
  getPosts(user.id, (err, posts) => {
    if (err) return handleError(err);
    getComments(posts[0].id, (err, comments) => {
      if (err) return handleError(err);
      render(user, posts, comments);
    });
  });
});

Promise version:

getUser(id)
  .then((user) => getPosts(user.id))
  .then((posts) => getComments(posts[0].id))
  .then((comments) => render(user, posts, comments))
  .catch(handleError);

async/await version:

try {
  const user = await getUser(id);
  const posts = await getPosts(user.id);
  const comments = await getComments(posts[0].id);
  render(user, posts, comments);
} catch (err) {
  handleError(err);
}

Summary of benefits

Problem with Callbacks How Promises Solve It
Repeated error handling in every callback Single .catch() handles all errors in a chain
Deeply nested, hard-to-read code Flat .then() chain or await reads top-to-bottom
Can't use try/catch async/await restores try/catch behavior
Hard to run things in parallel Promise.all(), Promise.race(), Promise.allSettled()
Error swallowing Unhandled rejections surface clearly in Node.js
Difficult to compose Promises are values — pass them, return them, store them

Putting It Together

Here is a complete, realistic example using the built-in https module, wrapped in Promises, using async/await:

const https = require("https");

function fetchJSON(url) {
  return new Promise((resolve, reject) => {
    https.get(url, (res) => {
      let raw = "";

      res.on("data", (chunk) => (raw += chunk));
      res.on("end", () => {
        try {
          resolve(JSON.parse(raw));
        } catch (err) {
          reject(new Error("Invalid JSON response"));
        }
      });
      res.on("error", reject);
    });
  });
}

async function getPostWithAuthor(postId) {
  const post = await fetchJSON(
    `https://jsonplaceholder.typicode.com/posts/${postId}`
  );

  const author = await fetchJSON(
    `https://jsonplaceholder.typicode.com/users/${post.userId}`
  );

  return {
    title: post.title,
    body: post.body,
    author: author.name,
    email: author.email,
  };
}

getPostWithAuthor(1)
  .then((result) => console.log(result))
  .catch((err) => console.error("Error:", err.message));

Conclusion

The progression from callbacks to Promises to async/await isn't just syntax preference — each step solves real, tangible problems that arise when writing non-trivial async code.

  • Callbacks work, but they don't compose well and they make error handling fragile.

  • Promises give you a proper value to work with, a flat chain, and a single place to handle errors.

  • async/await makes Promise-based code look and behave like synchronous code, without actually blocking.

Modern Node.js (and the browser) use Promises everywhere. fs.promises, fetch, and most third-party libraries return Promises by default. Learning to work with them fluently is not optional — it's the baseline for writing reliable Node.js code.

Start with the examples above, convert a callback-based function to a Promise yourself, and then rewrite it with async/await. The difference will be immediately apparent.