DEV Community

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

Posted on

Async Code in Node.js: Callbacks and Promises

The two patterns that make Node.js non-blocking — and how one evolved from the other.


Every Node.js developer faces this moment: you write what looks like perfectly logical code, and the output comes in the wrong order.

const fs = require("fs");

console.log("Before reading file");
fs.readFile("data.txt", "utf8", (err, content) => {
  console.log("File content:", content);
});
console.log("After reading file");
Enter fullscreen mode Exit fullscreen mode

Output:

Before reading file
After reading file
File content: Hello from data.txt!
Enter fullscreen mode Exit fullscreen mode

Wait — "After reading file" printed before the file content? The file read is in the middle of the code, but its result came last?

Welcome to async Node.js. This isn't a bug. This is exactly how Node.js is designed to work — and understanding why is the key to writing effective server-side JavaScript.

Let me walk you through the two async patterns that Node.js is built on: callbacks and Promises.


Why Async Code Exists in Node.js

Node.js runs on a single thread. That one thread has to handle every incoming request, every file read, every database query. If it waited for each slow operation to finish before moving on, one user reading a large file would freeze the server for everyone.

So Node.js uses a different approach: start the operation, move on, handle the result when it arrives.

The File Reading Scenario

Let's compare what happens with sync vs async when three users hit your server:

SYNCHRONOUS (blocking):
────────────────────────────────────────────────────
User A → Read file [████████ 100ms ████████] → Response
                                                User B → Read file [████████] → Response
                                                                                 User C → ...
Each user waits for the previous one. Total: 300ms for all three.

ASYNCHRONOUS (non-blocking):
────────────────────────────────────────────────────
User A → Start read → move on
User B → Start read → move on    (all three reads happen simultaneously)
User C → Start read → move on

  ...100ms later...
  All three file reads complete → Send all three responses

Total: ~100ms for all three. Same work, 3x faster.
Enter fullscreen mode Exit fullscreen mode

This is why async code exists. Node.js can't afford to wait.


Callback-Based Async Execution

A callback is a function you pass to an async operation, saying: "Run this function when you're done." It's the original async pattern in Node.js, and you'll see it everywhere in the built-in modules.

Basic File Read with Callback

const fs = require("fs");

fs.readFile("users.json", "utf8", (error, data) => {
  if (error) {
    console.log("Failed to read file:", error.message);
    return;
  }
  console.log("File data:", data);
});
Enter fullscreen mode Exit fullscreen mode

Let's trace the execution step by step:

Callback Execution Flow:
────────────────────────────────────────────────────

  1. fs.readFile() is called
     → Node.js tells the OS: "Read this file"
     → Node.js registers the callback: (error, data) => { ... }
     → Node.js DOES NOT WAIT. Returns immediately.

  2. Main thread continues with the next line of code
     → Other requests can be handled
     → Other callbacks can run

  3. File read completes (OS signals Node.js)
     → Callback is placed in the task queue

  4. Event loop checks: "Is the call stack empty?"
     → YES → Move callback from queue to stack
     → Callback runs: console.log("File data:", data)
Enter fullscreen mode Exit fullscreen mode

Node.js Error-First Convention

Every callback in Node.js core follows the same pattern: the first argument is always the error.

// The pattern:
someAsyncOperation((error, result) => {
  if (error) {
    // Handle error
    return;
  }
  // Use result
});
Enter fullscreen mode Exit fullscreen mode

This convention is so universal in Node.js that it has a name: error-first callbacks.

// File read
fs.readFile("file.txt", "utf8", (err, data) => { /* ... */ });

// Directory read
fs.readdir("./src", (err, files) => { /* ... */ });

// File write
fs.writeFile("output.txt", content, (err) => { /* ... */ });

// File stats
fs.stat("file.txt", (err, stats) => { /* ... */ });
Enter fullscreen mode Exit fullscreen mode

Always check the error first. Always.

Callback Execution Chain

When async operations depend on each other — each step needing the result of the previous step — callbacks get chained:

const fs = require("fs");

// Step 1: Read the config file
fs.readFile("config.json", "utf8", (err, configData) => {
  if (err) return console.log("Config error:", err.message);

  const config = JSON.parse(configData);
  console.log("1. Config loaded:", config.dbPath);

  // Step 2: Use config to read the database file
  fs.readFile(config.dbPath, "utf8", (err, dbData) => {
    if (err) return console.log("DB error:", err.message);

    const users = JSON.parse(dbData);
    console.log("2. Users loaded:", users.length);

    // Step 3: Write a report based on the data
    const report = `Total users: ${users.length}`;
    fs.writeFile("report.txt", report, (err) => {
      if (err) return console.log("Write error:", err.message);

      console.log("3. Report written successfully!");
    });
  });
});
Enter fullscreen mode Exit fullscreen mode
Callback Chain Visualization:

  readFile("config.json")
      │
      └──→ callback fires with config data
              │
              └──→ readFile(config.dbPath)
                      │
                      └──→ callback fires with db data
                              │
                              └──→ writeFile("report.txt")
                                      │
                                      └──→ callback: "Done!"

Each step depends on the previous step's result.
Each step is indented one level deeper.
Enter fullscreen mode Exit fullscreen mode

Problems with Nested Callbacks

That chain above? It's only 3 levels deep and it's already hard to follow. Now imagine 5, 6, or 7 levels. This is the infamous Callback Hell — also called the Pyramid of Doom.

A Real-World Nightmare

Imagine building an API endpoint that needs to:

  1. Authenticate the user
  2. Fetch their profile
  3. Get their order history
  4. Look up shipping details for the latest order
  5. Send the response
authenticate(token, (err, userId) => {
  if (err) return res.status(401).json({ error: "Unauthorized" });

  getProfile(userId, (err, profile) => {
    if (err) return res.status(500).json({ error: "Profile not found" });

    getOrders(userId, (err, orders) => {
      if (err) return res.status(500).json({ error: "Orders not found" });

      getShipping(orders[0].id, (err, shipping) => {
        if (err) return res.status(500).json({ error: "Shipping not found" });

        logActivity(userId, "viewed-order", (err) => {
          if (err) console.log("Log failed:", err.message);

          res.json({
            user: profile.name,
            latestOrder: orders[0],
            shipping: shipping.address,
          });
        });
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Why This Is a Problem

1. READABILITY
   → Code moves RIGHT instead of DOWN
   → 5 levels of indentation — hard to scan

2. ERROR HANDLING
   → Every single level has its own if (err) check
   → Duplicated error patterns — easy to forget one

3. MAINTAINABILITY
   → Adding a step means wrapping everything in another level
   → Removing a step means restructuring the entire chain

4. DEBUGGING
   → Stack traces are confusing with deep nesting
   → Finding which level caused an error is painful
Enter fullscreen mode Exit fullscreen mode
Visual  The Pyramid of Doom:

authenticate(token, (err, userId) => {
  ·getProfile(userId, (err, profile) => {
  ··getOrders(userId, (err, orders) => {
  ···getShipping(orders[0].id, (err, shipping) => {
  ····logActivity(userId, "viewed", (err) => {
  ·····res.json({ ... });  // 5 levels deep 😵
  ····});
  ···});
  ··});
  ·});
});

The code forms a pyramid shape →→→→→
Enter fullscreen mode Exit fullscreen mode

This is the fundamental problem that Promises were invented to solve.


Promise-Based Async Handling

A Promise is an object that represents the eventual result of an async operation — it will either succeed with a value or fail with an error.

The Promise Lifecycle

┌──────────────────────────────────────────────┐
│                                              │
│        new Promise((resolve, reject) => {    │
│          // async work                       │
│        })                                    │
│                                              │
│                  ┌─────────┐                 │
│                  │ PENDING  │                │
│                  └────┬────┘                 │
│                       │                      │
│            ┌──────────┴──────────┐           │
│            ↓                     ↓           │
│     ┌────────────┐        ┌───────────┐      │
│     │ FULFILLED  │        │ REJECTED  │      │
│     │ resolve()  │        │ reject()  │      │
│     └─────┬──────┘        └─────┬─────┘      │
│           ↓                     ↓            │
│       .then(value)          .catch(error)    │
│                                              │
│              .finally() — runs either way    │
│                                              │
└──────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Converting Node.js Callbacks to Promises

Node.js's fs.promises API provides Promise-based versions of all file system operations:

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

// Callback version
// fs.readFile("data.txt", "utf8", (err, data) => { ... });

// Promise version
fs.readFile("data.txt", "utf8")
  .then((data) => {
    console.log("File content:", data);
  })
  .catch((error) => {
    console.log("Error:", error.message);
  });
Enter fullscreen mode Exit fullscreen mode

You Can Also Wrap Callbacks Manually

If a library doesn't provide Promises, you can create your own:

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

// Now it works with .then()
readFilePromise("data.txt")
  .then((data) => console.log(data))
  .catch((err) => console.log(err.message));
Enter fullscreen mode Exit fullscreen mode

Node.js also provides a built-in utility for this: util.promisify():

const { promisify } = require("util");
const fs = require("fs");

const readFile = promisify(fs.readFile);

readFile("data.txt", "utf8")
  .then((data) => console.log(data))
  .catch((err) => console.log(err.message));
Enter fullscreen mode Exit fullscreen mode

The Same Chain — Callbacks vs Promises

Remember the 3-step config → database → report chain? Here's how it looks with Promises:

Callback Version (nested)

fs.readFile("config.json", "utf8", (err, configData) => {
  if (err) return console.log(err);
  const config = JSON.parse(configData);

  fs.readFile(config.dbPath, "utf8", (err, dbData) => {
    if (err) return console.log(err);
    const users = JSON.parse(dbData);

    fs.writeFile("report.txt", `Users: ${users.length}`, (err) => {
      if (err) return console.log(err);
      console.log("Done!");
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Promise Version (flat)

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

fs.readFile("config.json", "utf8")
  .then((configData) => {
    const config = JSON.parse(configData);
    return fs.readFile(config.dbPath, "utf8");
  })
  .then((dbData) => {
    const users = JSON.parse(dbData);
    return fs.writeFile("report.txt", `Users: ${users.length}`);
  })
  .then(() => {
    console.log("Done!");
  })
  .catch((error) => {
    console.log("Something failed:", error.message);
  });
Enter fullscreen mode Exit fullscreen mode

Async/Await Version (cleanest)

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

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

    const dbData = await fs.readFile(config.dbPath, "utf8");
    const users = JSON.parse(dbData);

    await fs.writeFile("report.txt", `Users: ${users.length}`);
    console.log("Done!");
  } catch (error) {
    console.log("Something failed:", error.message);
  }
}

generateReport();
Enter fullscreen mode Exit fullscreen mode

Side-by-Side Comparison

CALLBACKS:                        PROMISES:
─────────                         ─────────
readFile(cb1)                     readFile()
  ·readFile(cb2)                    .then(data => readFile())
  ··writeFile(cb3)                  .then(data => writeFile())
  ···console.log("Done")           .then(() => console.log("Done"))
                                    .catch(err => ...)

Nested → RIGHT                    Flat → DOWN
Error at every level              One catch for all
Hard to add/remove steps          Easy to modify
Enter fullscreen mode Exit fullscreen mode

Benefits of Promises

Benefit Callbacks Promises
Readability Nested, rightward drift Flat, top-to-bottom
Error handling Repeated if (err) at every level One .catch() for the entire chain
Composability Hard to combine operations Promise.all(), Promise.race()
Adding steps Wrap in another nesting level Add another .then()
Return values Must use callbacks to pass data Each .then() returns a new Promise
Debugging Confusing stack traces Clearer trace through the chain
Modern support Legacy pattern Built into Node.js, fs.promises

Concurrent Operations with Promise.all()

This is something callbacks can't do cleanly — running independent operations in parallel:

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

async function loadAllData() {
  // All three reads start simultaneously
  const [users, products, settings] = await Promise.all([
    fs.readFile("users.json", "utf8"),
    fs.readFile("products.json", "utf8"),
    fs.readFile("settings.json", "utf8"),
  ]);

  console.log("Users:", JSON.parse(users).length);
  console.log("Products:", JSON.parse(products).length);
  console.log("Settings loaded:", JSON.parse(settings).theme);
}

loadAllData();
Enter fullscreen mode Exit fullscreen mode

With callbacks, you'd need manual counters to track when all three are done. With Promise.all(), it's one line.


The 5-Level API — Rewritten with Promises

Remember the 5-level callback nightmare? Here it is, flattened:

// Callback Hell version — 5 levels of nesting
authenticate(token, (err, userId) => {
  getProfile(userId, (err, profile) => {
    getOrders(userId, (err, orders) => {
      getShipping(orders[0].id, (err, shipping) => {
        logActivity(userId, "viewed-order", (err) => {
          res.json({ user: profile.name, shipping: shipping.address });
        });
      });
    });
  });
});

// Promise version — completely flat
async function handleRequest(token, res) {
  try {
    const userId = await authenticate(token);
    const profile = await getProfile(userId);
    const orders = await getOrders(userId);
    const shipping = await getShipping(orders[0].id);
    await logActivity(userId, "viewed-order");

    res.json({
      user: profile.name,
      latestOrder: orders[0],
      shipping: shipping.address,
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
}
Enter fullscreen mode Exit fullscreen mode

Five levels of nesting → zero. Five error checks → one. Same logic, dramatically better code.


Let's Practice: Hands-On Assignment

Part 1: Callback-Based File Operations

const fs = require("fs");

// Write a file, then read it back
fs.writeFile("greeting.txt", "Hello from Node.js!", (err) => {
  if (err) return console.log("Write error:", err.message);
  console.log("File written!");

  fs.readFile("greeting.txt", "utf8", (err, data) => {
    if (err) return console.log("Read error:", err.message);
    console.log("File says:", data);
  });
});
Enter fullscreen mode Exit fullscreen mode

Part 2: Convert to Promises

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

async function greetingFile() {
  try {
    await fs.writeFile("greeting.txt", "Hello from Promises!");
    console.log("File written!");

    const data = await fs.readFile("greeting.txt", "utf8");
    console.log("File says:", data);
  } catch (error) {
    console.log("Error:", error.message);
  }
}

greetingFile();
Enter fullscreen mode Exit fullscreen mode

Part 3: Concurrent Reads with Promise.all()

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

async function setup() {
  // Create test files
  await fs.writeFile("file1.txt", "Data from file 1");
  await fs.writeFile("file2.txt", "Data from file 2");
  await fs.writeFile("file3.txt", "Data from file 3");

  // Read all three concurrently
  console.time("Concurrent read");
  const [f1, f2, f3] = await Promise.all([
    fs.readFile("file1.txt", "utf8"),
    fs.readFile("file2.txt", "utf8"),
    fs.readFile("file3.txt", "utf8"),
  ]);
  console.timeEnd("Concurrent read");

  console.log(f1, "|", f2, "|", f3);
}

setup();
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Async code exists in Node.js because the single thread can't afford to wait. Starting operations and handling results later keeps the server responsive.
  2. Callbacks are the original async pattern: pass a function, it gets called when the operation finishes. Node.js uses error-first callbacks (err as the first argument).
  3. Nested callbacks create the Pyramid of Doom — deep indentation, repeated error handling, and unmaintainable code.
  4. Promises flatten the nesting with .then() chains. Async/await makes it even cleaner with synchronous-looking syntax.
  5. Promises add real capabilities callbacks lack: Promise.all() for concurrent operations, one .catch() for all errors, and composable chains that are easy to modify.

Wrapping Up

Callbacks and Promises aren't competing patterns — they're an evolution. Callbacks came first and they work. But as applications grew more complex and async chains got deeper, the need for something more readable and maintainable became clear. Promises — and the async/await sugar on top — were the answer.

In Node.js, you'll encounter both patterns daily. Legacy code and some npm packages still use callbacks. Modern Node.js APIs (like fs.promises) and new codebases use Promises and async/await. Understanding both means you can work with any codebase.

I'm learning all of this through the ChaiCode Web Dev Cohort 2026 under Hitesh Chaudhary and Piyush Garg. Seeing the callback → Promise → async/await evolution play out with real Node.js file operations made the patterns tangible in a way that abstract examples never could.

Connect with me on LinkedIn or visit PrathamDEV.in. More articles on the way.

Happy coding! 🚀


Written by Pratham Bhardwaj | Web Dev Cohort 2026, ChaiCode

Top comments (0)