Skip to main content

Command Palette

Search for a command to run...

Async Code in Node.js: Callbacks and Promises

Published
5 min read
Async Code in Node.js: Callbacks and Promises

When you send a request to your Node.js server, you don't want it to freeze while waiting for a database to respond or a file to load. That's where asynchronous code comes in. It lets your server handle other requests while waiting for operations to complete.

Why Node.js Needs Asynchronous Code

Node.js runs on a single thread. If you tried to handle everything synchronously, your entire server would block while waiting for slow operations like reading files, querying databases, or making HTTP requests.

Think of it like a restaurant. If the waiter takes an order and then stands still waiting for the chef to finish cooking before serving another customer, he wastes time. But if the waiter takes an order, moves to the next customer, and comes back when the food is ready, he serves everyone efficiently.

Node.js works the same way. Asynchronous code lets your server accept multiple requests without blocking.

Callbacks: The Original Async Solution

A callback is a function you pass to another function. It gets executed when an operation finishes.

Here's a simple example of reading a file with a callback:

import fs from 'fs';

fs.readFile('data.txt', 'utf8', (error, data) => {
  if (error) {
    console.log('Error reading file:', error);
  } else {
    console.log('File contents:', data);
  }
});

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

When readFile starts, it doesn't wait. Instead, it sets up the callback and moves on to the next line. Once the file loads, the callback executes with either the error or the data.

The output shows this clearly:

Reading file...
File contents: [content of data.txt]

Notice that "Reading file..." prints first, even though it comes after readFile in the code. That's asynchronous execution.

The Problem: Callback Hell

Callbacks work fine for a single operation. But what if you need to do multiple things in sequence? You need to read one file, then use its contents to read another file, then process that data.

import fs from 'fs';

fs.readFile('users.txt', 'utf8', (error1, users) => {
  if (error1) {
    console.log('Error:', error1);
  } else {
    fs.readFile('posts.txt', 'utf8', (error2, posts) => {
      if (error2) {
        console.log('Error:', error2);
      } else {
        fs.readFile('comments.txt', 'utf8', (error3, comments) => {
          if (error3) {
            console.log('Error:', error3);
          } else {
            console.log('All files loaded:', users, posts, comments);
          }
        });
      }
    });
  }
});

This deeply nested structure is called "callback hell" or the "pyramid of doom." It's hard to read, error-prone, and difficult to maintain. Imagine if you needed five or ten operations. The code would become unreadable.

Promises: A Better Way

Promises were introduced to solve callback hell. A promise is an object that represents a value that might be available now, later, or never.

A promise has three states:

  1. Pending: The operation hasn't finished yet.

  2. Fulfilled: The operation completed successfully.

  3. Rejected: The operation failed.

Here's how you create a promise:

const promise = new Promise((resolve, reject) => {
  // Do something async
  if (success) {
    resolve(value); // fulfilled
  } else {
    reject(error); // rejected
  }
});

The callback receives two functions: resolve and reject. You call resolve when the operation succeeds and reject when it fails.

Using Promises with .then()

Once you have a promise, you can attach handlers using .then():

import fs from 'fs/promises';

fs.readFile('data.txt', 'utf8')
  .then(data => {
    console.log('File contents:', data);
  })
  .catch(error => {
    console.log('Error:', error);
  });

The .then() method runs when the promise is fulfilled, and .catch() runs if it's rejected.

Chaining Promises

The real power of promises comes from chaining them. Each .then() returns a new promise, so you can chain multiple operations:

import fs from 'fs/promises';

fs.readFile('users.txt', 'utf8')
  .then(users => {
    console.log('Users loaded');
    return fs.readFile('posts.txt', 'utf8');
  })
  .then(posts => {
    console.log('Posts loaded');
    return fs.readFile('comments.txt', 'utf8');
  })
  .then(comments => {
    console.log('Comments loaded');
    console.log('All files loaded successfully');
  })
  .catch(error => {
    console.log('Error:', error);
  });

This is much cleaner than nested callbacks. Each step is at the same indentation level, making it easy to follow the flow.

Promise Lifecycle Visualization

Here's how a promise moves through its states:

Promise Created (Pending)
         |
    -----+-----
   |          |
Resolved    Rejected
(Fulfilled)  (Error)
   |          |
.then()     .catch()

Once a promise is settled (either fulfilled or rejected), it doesn't change. You can attach multiple .then() handlers, and they all receive the same result.

Error Handling with Promises

Promises make error handling cleaner. A single .catch() handles errors from any step in the chain:

import fs from 'fs/promises';

fs.readFile('file1.txt', 'utf8')
  .then(data1 => fs.readFile('file2.txt', 'utf8'))
  .then(data2 => fs.readFile('file3.txt', 'utf8'))
  .then(data3 => console.log('All files loaded'))
  .catch(error => console.log('Error in any step:', error));

If any .then() throws an error or rejects, the .catch() will handle it. You don't need to check for errors at each step.

Common Mistakes

Mistake 1: Forgetting to return promises in chains

// Wrong
fs.readFile('file1.txt', 'utf8')
  .then(data1 => {
    fs.readFile('file2.txt', 'utf8'); // Missing return
  })
  .then(data2 => {
    // data2 will be undefined
  });

// Correct
fs.readFile('file1.txt', 'utf8')
  .then(data1 => {
    return fs.readFile('file2.txt', 'utf8');
  })
  .then(data2 => {
    // data2 has the file contents
  });

Mistake 2: Not catching rejected promises

// Can leave unhandled rejections
fs.readFile('nonexistent.txt', 'utf8')
  .then(data => console.log(data));

// Better
fs.readFile('nonexistent.txt', 'utf8')
  .then(data => console.log(data))
  .catch(error => console.log(error));

Key Takeaways

  • Node.js is single-threaded, so asynchronous code is essential to avoid blocking.

  • Callbacks were the original async solution but lead to callback hell with nested operations.

  • Promises provide a cleaner way to handle async operations with .then() and .catch().

  • Promise chains let you handle multiple sequential operations readably.

  • A single .catch() handles errors from any step in the chain.

Understanding callbacks and promises is fundamental to working with Node.js. Once you're comfortable with these, async/await (which we'll cover separately) makes async code look almost like synchronous code while keeping the benefits of non-blocking execution.