Skip to main content

Command Palette

Search for a command to run...

Async Code in Node.js: Callbacks and Promises

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

Introduction: The Sync vs. Async Reality Check

Imagine you're building a web server that needs to read a file from disk. In a traditional, synchronous world, your code would look something like this:

const fs = require('fs');

const data = fs.readFileSync('myfile.txt', 'utf8');
console.log(data);
console.log('Server is running...');

This works fine for a simple script. But here's the problem: what if reading that file takes 2 seconds? Your entire server freezes for 2 seconds. Every user request gets stuck waiting. That's a disaster for a web application.

This is where asynchronous code comes in. Node.js was built from the ground up to handle async operations, allowing your server to keep working while waiting for slow tasks like file reading, database queries, or API calls to complete.

In this blog, we'll explore why async code exists in Node.js, how callbacks work, their problems, and how promises solve those issues.

Why Async Code Exists in Node.js

Node.js runs on a single-threaded event loop. This means it has one main thread that executes your JavaScript code. At first glance, this sounds limiting. How can one thread handle thousands of concurrent users?

The secret is non-blocking I/O.

When Node.js encounters an I/O operation (like reading a file, querying a database, or making an HTTP request), it doesn't wait for it to finish. Instead, it:

  1. Delegates the task to the system (or a thread pool)

  2. Continues executing the rest of your code

  3. Comes back to handle the result when the task is done

The Coffee Shop Analogy

Think of Node.js like a coffee shop with one barista (the single thread). When a customer orders a latte:

  • Synchronous approach: The barista takes the order, makes the coffee, hands it over, then takes the next order. If making coffee takes 3 minutes, every other customer waits 3 minutes.

  • Asynchronous approach: The barista takes the order, starts the espresso machine (which works in the background), takes orders from other customers, and only returns to finish the latte when the machine beeps.

Node.js is the efficient barista. It keeps serving other customers while waiting for slow operations to complete.

Why does this matter?

  • Web servers handle lots of I/O (databases, files, APIs)

  • Async code lets one server handle thousands of concurrent connections

  • Your application stays responsive even under load

Callbacks: The Original Async Pattern

Before promises existed, callbacks were the primary way to handle async operations in Node.js. A callback is simply a function passed as an argument to another function, which gets called ("called back") when the async operation completes.

Let's revisit our file reading example using callbacks:

const fs = require('fs');

fs.readFile('myfile.txt', 'utf8', (error, data) => {
  if (error) {
    console.error('Failed to read file:', error);
    return;
  }
  console.log('File contents:', data);
});

console.log('Server is running...');

Step-by-Step Execution Flow

  1. fs.readFile() is called with a file path, encoding, and a callback function

  2. Node.js starts reading the file in the background

  3. The code immediately moves to the next line and prints "Server is running..."

  4. When the file reading completes (maybe 2 seconds later), Node.js executes the callback function

  5. The callback checks for errors and logs the file contents

Notice the output order:

Server is running...
File contents: [file data]

The "Server is running..." message appears before the file contents, even though it's written after fs.readFile() in the code. This is the essence of async execution!

Visualizing Callback Execution

Time →

[fs.readFile starts]
         ↓
[Console.log runs] → "Server is running..."
         ↓
[File reading completes in background]
         ↓
[Callback executes] → "File contents: ..."

The Callback Problem: Nesting & Callback Hell

Callbacks work fine for simple async operations. But real-world code often requires multiple async operations that depend on each other.

Imagine you need to:

  1. Read a user file

  2. Parse the user data

  3. Read their preferences file

  4. Update the database

With callbacks, this looks like:

const fs = require('fs');

fs.readFile('user.txt', 'utf8', (err, userData) => {
  if (err) {
    console.error('Error reading user:', err);
    return;
  }
  
  const user = JSON.parse(userData);
  
  fs.readFile('preferences.txt', 'utf8', (err, prefData) => {
    if (err) {
      console.error('Error reading preferences:', err);
      return;
    }
    
    const preferences = JSON.parse(prefData);
    
    db.updateUser(user.id, preferences, (err, result) => {
      if (err) {
        console.error('Error updating database:', err);
        return;
      }
      
      console.log('User updated successfully:', result);
    });
  });
});

The Problem: Callback Hell

This nesting pattern is called "Callback Hell" or the "Pyramid of Doom" because the code forms a pyramid shape that:

  • Is hard to read: You're constantly indenting deeper

  • Is hard to maintain: Adding or removing steps requires restructuring

  • Makes error handling repetitive: Every callback needs its own error check

  • Obscures the logical flow: It's hard to see the sequence of operations

Common Beginner Mistakes with Callbacks

  1. Forgetting error handling: Not checking err in every callback

  2. Not returning after errors: Code continues executing after an error

  3. Throwing exceptions in callbacks: Async errors can't be caught with try/catch

  4. Losing context: Variables from outer scopes can be confusing

Promises: A Cleaner Way to Handle Async

Promises were introduced to solve the problems with callbacks. A Promise is an object that represents the eventual completion (or failure) of an async operation and its resulting value.

Promise States

A promise can be in one of three states:

  • Pending: Initial state, neither fulfilled nor rejected

  • Fulfilled (Resolved): Operation completed successfully

  • Rejected: Operation failed

Promise Lifecycle Flow

         [Pending]
            ↓
      ┌─────┴─────┐
      ↓                  ↓
 [Fulfilled]        [Rejected]
      ↓           ↓
  .then()             .catch()

Converting Our Example to Promises

Modern Node.js provides promise-based versions of fs methods in fs.promises:

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

fs.readFile('user.txt', 'utf8')
  .then((userData) => {
    const user = JSON.parse(userData);
    return fs.readFile('preferences.txt', 'utf8');
  })
  .then((prefData) => {
    const preferences = JSON.parse(prefData);
    return db.updateUser(user.id, preferences);
  })
  .then((result) => {
    console.log('User updated successfully:', result);
  })
  .catch((error) => {
    console.error('An error occurred:', error);
  });

How Promises Work

  1. fs.readFile() returns a Promise object immediately

  2. .then() attaches a callback that runs when the promise fulfills

  3. Each .then() can return a value (or another promise), which passes to the next .then()

  4. .catch() handles any error that occurs in the chain

  5. The code reads top-to-bottom, matching the logical flow

Key Improvements Over Callbacks

  • Flat structure: No more nesting/pyramid

  • Centralized error handling: One .catch() handles all errors

  • Chainable: Each .then() returns a promise for the next step

  • Readable: The sequence of operations is clear

Why Promises Win: Benefits & Readability Comparison

Let's directly compare the callback and promise versions side by side:

Callback Version (Nested)

fs.readFile('user.txt', 'utf8', (err, userData) => {
  if (err) { /* handle */ return; }
  const user = JSON.parse(userData);
  
  fs.readFile('preferences.txt', 'utf8', (err, prefData) => {
    if (err) { /* handle */ return; }
    const preferences = JSON.parse(prefData);
    
    db.updateUser(user.id, preferences, (err, result) => {
      if (err) { /* handle */ return; }
      console.log('Success:', result);
    });
  });
});

Promise Version (Chained)

fs.readFile('user.txt', 'utf8')
  .then(userData => JSON.parse(userData))
  .then(user => fs.readFile('preferences.txt', 'utf8'))
  .then(prefData => JSON.parse(prefData))
  .then(preferences => db.updateUser(user.id, preferences))
  .then(result => console.log('Success:', result))
  .catch(error => console.error('Error:', error));

Benefits of Promises

  1. Readability: Linear, top-to-bottom flow vs. nested indentation

  2. Error Handling: One .catch() vs. error checks in every callback

  3. Composability: Easy to chain, combine, and reuse promises

  4. Better Debugging: Stack traces are clearer

  5. Standardization: Promises are part of JavaScript's core (ES6)

  6. Foundation for async/await: Modern syntax builds on promises

Common Beginner Mistakes with Promises

  1. Forgetting to return values: Each .then() must return a value or promise for the next step

  2. Not handling rejections: Always include a .catch() or your errors will be silent

  3. Mixing callbacks and promises: Stick to one pattern per codebase

  4. Creating unnecessary promise wrappers: Use promise-based APIs when available

Conclusion & Next Steps

You've now learned:

  • Why Node.js uses async code (single-threaded, non-blocking I/O)

  • How callbacks work (functions passed to handle async results)

  • Problems with callbacks (nesting, callback hell, error handling)

  • How promises solve these issues (chaining, flat structure, centralized errors)

  • Why promises are better (readability, maintainability, standardization)

What's Next?

Promises are a huge improvement, but there's an even more modern syntax: async/await. It lets you write async code that looks synchronous, making it even easier to read and maintain.

Here's a sneak peek:

async function updateUser() {
  try {
    const userData = await fs.readFile('user.txt', 'utf8');
    const user = JSON.parse(userData);
    
    const prefData = await fs.readFile('preferences.txt', 'utf8');
    const preferences = JSON.parse(prefData);
    
    const result = await db.updateUser(user.id, preferences);
    console.log('Success:', result);
  } catch (error) {
    console.error('Error:', error);
  }
}

Async/await is built on top of promises, so understanding promises is essential. Once you're comfortable with promises, async/await will feel natural.