# Async Code in Node.js: Callbacks and Promises

* * *

## Introduction

Imagine this situation. You hit an API endpoint that reads a file and returns its content. Now two users send request at the same time.

*   One request is reading a large file.
    
*   The other just needs a quick response
    

If your server waits for the file to finish before handling the second request, the second user experiences unnecessary delay.

This is exactly the kind of problem Node.js was designed to avoid.

Instead if waiting, Node.js keeps moving and that is where asynchronous code comes into the picture.

In this blog, we will break down how that works using,

*   Callbacks (the original approach)
    
*   Promises (a cleaner evolution)
    

* * *

## Why Async Code Exists in Node.js

Node.js is single-threaded, which means it can run only one task at a time on the main thread.

Now imagine this.

You’re building a server and a user requests a file. Reading that file from disk takes time.

If Node.js waits for that file to be read before doing anything else, your entire server gets stuck.

That’s where asynchronous (async) code comes in.

Instead of waiting, Node.js:

*   Starts the task (like reading a file)
    
*   Moves on to handle other requests
    
*   Comes back when the task is done
    

* * *

### Simple Analogy: Restaurant Orders

Think of a chef,

*   A blocking chef waits for one dish to finish before starting another
    
*   A Node.js chef starts cooking, then takes the next order while the first one is still in progress
    

That is async behavior.

* * *

## Starting Point: File Reading Example

Let’s say we want to read a file.

### Blocking (Not how Node.js prefers)

```javascript
import fs from 'fs';

const data = fs.readFileSync('file.txt', 'utf-8');
console.log(data);

console.log('Done');
```

**Here,** `Done` **prints after the file is read.**

* * *

### Non-Blocking (Async way)

```javascript
import fs from 'fs';

fs.readFile('file.txt', 'utf-8', (err, data) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(data);
});

console.log('Done');
```

Output order:

```plaintext
Done
(file content later)
```

**Node.js didn’t wait. It moved forward.**

* * *

## Callback-Based Async Execution

A callback is just a function passed into another function, to be executed alter.

In our above example,

```javascript
(err, data) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(data);
}
```

This function runs after the file is read.

* * *

## Callback Flow (Step-by-Step)

Let’s simplify what happens internally:

1.  You call `fs.readFile()`
    
2.  Node.js sends the task to the system (background worker)
    
3.  Node.js continues executing next lines
    
4.  Once file reading is done --> callback is pushed to the queue
    
5.  Event loop executes the callback
    

This is how Node.js handles multiple operations efficiently.

* * *

### Problem: Nested Callbacks (Callback Hell)

Callbacks work fine... until they don't.

Imagine multiple aynch steps,

```javascript
import fs from 'fs';

fs.readFile('file1.txt', 'utf-8', (err, data1) => {
  if (err) return console.error(err);

  fs.readFile('file2.txt', 'utf-8', (err, data2) => {
    if (err) return console.error(err);

    fs.readFile('file3.txt', 'utf-8', (err, data3) => {
      if (err) return console.error(err);

      console.log(data1, data2, data3);
    });
  });
});
```

Problems here:

*   Hard to read
    
*   Hard to debug
    
*   Error handling becomes messy
    
*   Deep nesting (“pyramid of doom”)
    

* * *

## Promise-Based Async Handling

Promises solve this readability problem.

A Promise represents a value that will be available in the future

```javascript
import { readFile } from 'fs/promises';

readFile('file1.txt', 'utf-8')
  .then((data1) => {
    return readFile('file2.txt', 'utf-8')
      .then((data2) => {
        return readFile('file3.txt', 'utf-8')
          .then((data3) => {
            console.log(data1, data2, data3);
          });
      });
  })
  .catch((err) => console.error(err));
```

Still a bit nested — but better structured.

* * *

### Cleaner Promise Chaining

```javascript
import { readFile } from 'fs/promises';

readFile('file1.txt', 'utf-8')
  .then((data1) => {
    console.log(data1);
    return readFile('file2.txt', 'utf-8');
  })
  .then((data2) => {
    console.log(data2);
    return readFile('file3.txt', 'utf-8');
  })
  .then((data3) => {
    console.log(data3);
  })
  .catch((err) => console.error(err));
```

Now the flow is,  
Step → Step → Step  
instead of deep nesting

* * *

## Benefits of Promises

1.  **Better Readability**  
    Code flows in a linear way instead of nested blocks.
    

2.  **Centralized Error Handling**  
    One .catch() handles all errors.
    

3.  **Easier Debugging**  
    Clear execution chain makes issues easier to trace.
    

4.  **Composability**  
    You can combine multiple promises (Promise.all, etc.)
    

* * *

### Callback vs Promise (Quick Comparison)

| Aspect | Callback | Promise |
| --- | --- | --- |
| Structure | Nested | Chained |
| Readability | Hard with depth | Cleaner |
| Error Handling | Manual everywhere | Centralized |
| Debugging | Difficult | Easier |

* * *

## Final Insight: Why This Matters

Node.js handles thousands of requests because it does not block execution.

Async code ensures:

*   Faster response handling
    
*   Better scalability
    
*   Efficient resource usage
    

But writing async code properly is just as important as using it.

Callbacks were the foundation of async programming in Node.js, but they quickly become hard to manage at scale.

In modern Node.js, promises (and later `async/await`) are the preferred way to handle async code.

* * *
