Skip to main content

Command Palette

Search for a command to run...

Async Code in Node.js: Callbacks and Promises

Updated
8 min read
Async Code in Node.js: Callbacks and Promises
H
CS undergrad | Tech enthusiast | Focusing on Web Dev • DSA • ML | Building skills for real-world impact

JavaScript is single-threaded, which means it can only do one thing at a time. But web servers need to handle hundreds of requests simultaneously. That's where asynchronous code comes in — it lets you start a task, move on to something else, and get notified when that task finishes.

In this article we will cover how async code works in Node.js, starting with callbacks and moving into the more modern promise-based approach.


Why Async Code Exists

Imagine you're reading a file from disk. This takes time — maybe 100 milliseconds. In synchronous code, your entire application would freeze and wait. That's wasteful.

// Synchronous — blocks everything
let data = fs.readFileSync("data.txt");
console.log("Data read:", data); // Nothing else happens until this completes

With async code, you can start the read and let Node.js continue processing other tasks while the file operation happens in the background:

// Asynchronous — non-blocking
fs.readFile("data.txt", (err, data) => {
  console.log("Data read:", data);
});
console.log("This runs immediately, no waiting");

This is how Node.js handles thousands of concurrent connections without breaking a sweat.


Callback-Based Async Execution

A callback is a function you pass to another function. That function runs your callback when the async operation completes.

Simple Callback Example

const fs = require("fs");

function readMyFile(filename, callback) {
  fs.readFile(filename, "utf8", (err, data) => {
    if (err) {
      callback(err, null);
    } else {
      callback(null, data);
    }
  });
}

readMyFile("message.txt", (err, data) => {
  if (err) {
    console.log("Error:", err);
  } else {
    console.log("File contents:", data);
  }
});

console.log("Reading file...");

Output:

Reading file...
File contents: Hello, World!

The callback fires when the file finishes reading. Your code doesn't block — it moves on immediately.

Execution Flow

Start: readMyFile() is called
  |
  +---> fs.readFile() begins (non-blocking)
  |
  +---> JavaScript continues running
  |     console.log("Reading file...") outputs
  |
  +---> [Waiting for file I/O to complete]
  |
  +---> File arrives from disk
  |
  +---> Callback fires
        if (err) or console.log outputs

Problems with Nested Callbacks — Callback Hell

When you need to do multiple async operations in sequence, callbacks get nested deeper and deeper. This is called "callback hell" or "the pyramid of doom."

Real-world scenario: Read a file, parse it, save the result

const fs = require("fs");

fs.readFile("input.txt", "utf8", (err, data) => {
  if (err) {
    console.log("Error reading file:", err);
  } else {
    // Parse the data
    let parsed = JSON.parse(data);
    
    fs.readFile("template.txt", "utf8", (err2, template) => {
      if (err2) {
        console.log("Error reading template:", err2);
      } else {
        // Combine data and template
        let result = template.replace("{data}", parsed.name);
        
        fs.writeFile("output.txt", result, (err3) => {
          if (err3) {
            console.log("Error writing file:", err3);
          } else {
            console.log("Done! Output written.");
          }
        });
      }
    });
  }
});

Notice how deeply nested this becomes. Each level adds another layer of indentation. With more operations, it becomes a nightmare to read and maintain.

The "Pyramid of Doom"

fs.readFile(..., (err, data) => {
  if (!err) {
    fs.readFile(..., (err2, template) => {
      if (!err2) {
        fs.writeFile(..., (err3) => {
          if (!err3) {
            // Finally, we can do something
            console.log("Success!");
          }
        });
      }
    });
  }
});

Problems:

  • Hard to read and follow the logic
  • Error handling scattered everywhere
  • Easy to lose track of variable scope
  • Difficult to refactor or test

Promise-Based Async Handling

A Promise represents an async operation that will eventually produce a result. Instead of nesting callbacks, you chain .then() calls to handle what happens next.

Promise Basics

const fs = require("fs").promises; // Note: .promises API

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

console.log("Reading file...");

Output:

Reading file...
File read: Hello, World!

The .then() runs when the file is read. The .catch() runs if there's an error.

Promise Lifecycle

Every promise goes through these states:

       ┌─────────┐
       │ Pending │  (operation not done yet)
       └────┬────┘
            |
     ┌──────┴──────┐
     |             |
  ┌──▼──┐      ┌──▼──┐
  │Fulfilled│  │Rejected│  (operation done)
  │(success)│  │(error) │
  └────────┘   └────────┘
     |             |
   .then()      .catch()

Solving Callback Hell with Promises

The same scenario (read file, parse, read template, write output) becomes much cleaner:

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

fs.readFile("input.txt", "utf8")
  .then(data => {
    let parsed = JSON.parse(data);
    return fs.readFile("template.txt", "utf8")
      .then(template => {
        return { parsed, template };
      });
  })
  .then(({ parsed, template }) => {
    let result = template.replace("{data}", parsed.name);
    return fs.writeFile("output.txt", result);
  })
  .then(() => {
    console.log("Done! Output written.");
  })
  .catch(err => {
    console.log("Error:", err);
  });

Much flatter. All error handling in one .catch() block at the end. The flow is easier to follow.

Creating Your Own Promise

function delay(ms) {
  return new Promise((resolve, reject) => {
    if (ms < 0) {
      reject("Time cannot be negative");
    } else {
      setTimeout(() => {
        resolve("Delay complete!");
      }, ms);
    }
  });
}

delay(2000)
  .then(result => console.log(result))
  .catch(err => console.log(err));

console.log("Waiting...");

Output:

Waiting...
Delay complete! (after 2 seconds)

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

  • resolve — call this when the operation succeeds
  • reject — call this when something goes wrong

Benefits of Promises

Aspect Callbacks Promises
Readability Deeply nested, hard to follow Flat, left-to-right flow
Error handling Multiple try-catch or if statements Single .catch() block
Chaining Not possible, leads to nesting Natural chain with .then()
Debugging Nested stack traces Clearer error messages
Parallelism Difficult to coordinate Promise.all() and Promise.race()

Promise.all() — Wait for Multiple Operations

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

Promise.all([
  fs.readFile("file1.txt", "utf8"),
  fs.readFile("file2.txt", "utf8"),
  fs.readFile("file3.txt", "utf8")
])
  .then(results => {
    console.log("File 1:", results[0]);
    console.log("File 2:", results[1]);
    console.log("File 3:", results[2]);
  })
  .catch(err => console.log("Error:", err));

All three files are read in parallel. The .then() fires once all three are done.

Promise.race() — Return First Result

Promise.race([
  delay(1000),
  delay(3000),
  delay(2000)
])
  .then(result => console.log("First one done:", result));

The fastest promise wins. Useful for timeouts or competing requests.


Callback vs Promise Side-by-Side

Reading and writing a file with callbacks:

fs.readFile("input.txt", "utf8", (err, data) => {
  if (err) {
    console.log("Read error:", err);
  } else {
    let processed = data.toUpperCase();
    fs.writeFile("output.txt", processed, (err) => {
      if (err) {
        console.log("Write error:", err);
      } else {
        console.log("Done!");
      }
    });
  }
});

Same thing with promises:

fs.readFile("input.txt", "utf8")
  .then(data => {
    let processed = data.toUpperCase();
    return fs.writeFile("output.txt", processed);
  })
  .then(() => console.log("Done!"))
  .catch(err => console.log("Error:", err));

Promises are flatter and the error handling is unified.


Practice Assignment

Work through these steps using Node.js file operations:

1. Create a simple function that returns a promise:

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

2. Use .then() to handle the result:

fetchData("data.txt")
  .then(data => console.log("Data:", data))
  .catch(err => console.log("Error:", err));

3. Chain multiple operations:

fetchData("input.txt")
  .then(data => {
    console.log("Read successful");
    return data.toUpperCase();
  })
  .then(processed => {
    console.log("Processed:", processed);
  })
  .catch(err => console.log("Error:", err));

4. Try Promise.all() with multiple files:

Promise.all([
  fetchData("file1.txt"),
  fetchData("file2.txt"),
  fetchData("file3.txt")
])
  .then(results => console.log("All files read:", results))
  .catch(err => console.log("Error:", err));

Run these in your Node.js environment and observe how promises handle async operations.


Quick Recap

  • Node.js is single-threaded, so async code is essential for handling concurrent operations

  • Callbacks are functions you pass to other functions. They run when the async operation completes

  • Nested callbacks create "callback hell" — deeply indented, hard to read code

  • Promises represent operations that will eventually produce a result — either success or failure

  • Promises have three states: Pending, Fulfilled, and Rejected

  • .then() handles success, .catch() handles errors

  • Promises allow clean chaining instead of nesting, making code more readable

  • Promise.all() waits for multiple promises in parallel

  • Promise.race() returns the first promise to complete

  • Modern async/await syntax builds on promises (covered in future articles)

Promises transformed how JavaScript handles async operations. Once you master them, async code becomes intuitive and maintainable.

Happy coding! 🚀


If you enjoyed this article, check out my other blogs on this profile. 🔗 Connect with me: LinkedIn | GitHub | X (Twitter)