# Async Code in Node.js: Callbacks and Promises

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.

```javascript
// 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:

```javascript
// 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

```javascript
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

```plaintext
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

```javascript
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"

```plaintext
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

```javascript
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:

```plaintext
       ┌─────────┐
       │ 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:

```javascript
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

```javascript
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

```javascript
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

```javascript
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:**

```javascript
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:**

```javascript
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:**

```javascript
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:**

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

**3. Chain multiple operations:**

```javascript
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:**

```javascript
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](https://linkedin.com/in/rajharsh03) | [GitHub](https://github.com/RajHarsh03) | [X (Twitter)](https://x.com/rajharsh03)
