Skip to main content

Command Palette

Search for a command to run...

Async Code in Node.js: Callbacks and Promises

Understanding asynchronous code execution from callbacks to promises

Updated
4 min read
J

Turning chai into code and ideas into full-stack applications. Sharing lessons from my development journey, one commit at a time.


Why Async Code Exists in Node.js

Node.js is single-threaded and non-blocking. When performing I/O operations like reading files, making HTTP requests, or querying databases, Node.js doesn't wait for these operations to complete. Instead, it continues executing other code and handles the result when ready.

Why this matters:

  • Prevents blocking the main thread

  • Enables handling multiple operations concurrently

  • Improves application performance and responsiveness


Callback-Based Async Execution

Callbacks are functions passed as arguments to async operations, executed when the operation completes.

File Reading Example

const fs = require('fs');

// Callback-based file reading
fs.readFile('data.txt', 'utf8', (error, data) => {
  if (error) {
    console.error('Error reading file:', error);
    return;
  }
  console.log('File content:', data);
});

console.log('This runs before file is read');

Execution Flow:

  1. readFile() is called

  2. Node.js delegates file reading to background worker

  3. "This runs before file is read" prints immediately

  4. When file reading completes, callback executes

  5. File content is logged


Problems with Nested Callbacks

When multiple async operations depend on each other, callbacks create deeply nested code (callback hell).

Callback Hell Example

const fs = require('fs');

// Reading three files sequentially
fs.readFile('file1.txt', 'utf8', (err1, data1) => {
  if (err1) {
    console.error(err1);
    return;
  }
  
  fs.readFile('file2.txt', 'utf8', (err2, data2) => {
    if (err2) {
      console.error(err2);
      return;
    }
    
    fs.readFile('file3.txt', 'utf8', (err3, data3) => {
      if (err3) {
        console.error(err3);
        return;
      }
      
      console.log('All files read:', data1, data2, data3);
    });
  });
});

Issues:

  • Deep nesting reduces readability

  • Error handling becomes repetitive

  • Code grows horizontally instead of vertically

  • Difficult to maintain and debug


Promise-Based Async Handling

Promises provide a cleaner way to handle async operations. A promise represents a value that may be available now, later, or never.

Promise States

  1. Pending: Initial state, operation not completed

  2. Fulfilled: Operation completed successfully

  3. Rejected: Operation failed

Using Promises

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

// Promise-based file reading
fs.readFile('data.txt', 'utf8')
  .then(data => {
    console.log('File content:', data);
  })
  .catch(error => {
    console.error('Error reading file:', error);
  });

Promise Chaining

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

// Reading three files sequentially with promises
fs.readFile('file1.txt', 'utf8')
  .then(data1 => {
    console.log('File 1:', data1);
    return fs.readFile('file2.txt', 'utf8');
  })
  .then(data2 => {
    console.log('File 2:', data2);
    return fs.readFile('file3.txt', 'utf8');
  })
  .then(data3 => {
    console.log('File 3:', data3);
  })
  .catch(error => {
    console.error('Error reading files:', error);
  });

Async/Await (Modern Syntax)

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

async function readFiles() {
  try {
    const data1 = await fs.readFile('file1.txt', 'utf8');
    const data2 = await fs.readFile('file2.txt', 'utf8');
    const data3 = await fs.readFile('file3.txt', 'utf8');
    
    console.log('All files:', data1, data2, data3);
  } catch (error) {
    console.error('Error reading files:', error);
  }
}

readFiles();

Benefits of Promises

1. Improved Readability

Promises eliminate nested callbacks and create linear, readable code flow.

2. Better Error Handling

Single .catch() block handles errors from entire promise chain.

fetch('api/users')
  .then(response => response.json())
  .then(users => users.filter(u => u.active))
  .then(activeUsers => console.log(activeUsers))
  .catch(error => console.error('Any error caught here:', error));

3. Easier Composition

Promises can be combined using utility methods:

// Run promises in parallel
Promise.all([
  fs.readFile('file1.txt', 'utf8'),
  fs.readFile('file2.txt', 'utf8'),
  fs.readFile('file3.txt', 'utf8')
])
  .then(([data1, data2, data3]) => {
    console.log('All files read simultaneously');
  })
  .catch(error => console.error(error));

4. Chaining Operations

Each .then() returns a new promise, enabling sequential operations.

getUserById(userId)
  .then(user => getOrdersByUser(user.id))
  .then(orders => calculateTotal(orders))
  .then(total => applyDiscount(total))
  .then(finalAmount => console.log('Final:', finalAmount))
  .catch(error => console.error(error));

Callback vs Promise Comparison

Aspect Callbacks Promises
Readability Nested, hard to follow Linear, easy to read
Error Handling Repeated in each callback Single .catch() block
Composition Difficult Easy with Promise.all(), Promise.race()
Debugging Stack traces unclear Better stack traces
Code Growth Horizontal (nested) Vertical (chained)

Key Takeaways

  • Node.js uses async code to prevent blocking operations

  • Callbacks execute when async operations complete

  • Nested callbacks create "callback hell" with poor readability

  • Promises represent eventual completion or failure of async operations

  • Promises improve code readability, error handling, and composition

  • Async/await provides synchronous-looking syntax for promises

  • Use promises (or async/await) for cleaner async code