← Back to Tutorials Chapter 2

Asynchronous Programming in Node.js

Why Asynchronous Programming?

Node.js excels at asynchronous programming because it's built on a non-blocking I/O model. This means while one operation is waiting (e.g., reading a file or making a network request), the event loop can handle other tasks. Understanding async patterns is essential for writing efficient Node.js code.

Callbacks

Callbacks are the original async pattern in Node.js. A callback is a function passed as an argument to another function that gets called when the operation completes.

const fs = require('fs');

// Asynchronous read with callback
fs.readFile('data.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Error reading file:', err);
    return;
  }
  console.log('File content:', data);
});

console.log('Reading file...'); // This runs BEFORE the callback
Common Convention: Node.js callbacks follow the "error-first" pattern — the first parameter is an error object (or null if successful), and subsequent parameters contain the result.

Callback Hell

Nesting callbacks leads to deeply indented code that's hard to read and maintain:

fs.readdir('folder', (err, files) => {
  if (err) throw err;
  fs.readFile(files[0], 'utf8', (err, data) => {
    if (err) throw err;
    fs.writeFile('output.txt', data, (err) => {
      if (err) throw err;
      console.log('Done!');
    });
  });
});

Promises

Promises provide a cleaner way to handle asynchronous operations. A Promise represents a value that may be available now, in the future, or never.

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

// Asynchronous read with Promise
fs.readFile('data.txt', 'utf8')
  .then(data => {
    console.log('File content:', data);
    return fs.writeFile('output.txt', data);
  })
  .then(() => console.log('Successfully wrote to output.txt'))
  .catch(err => console.error('Error:', err));

console.log('Reading file...'); // This still runs first

Creating a Promise

function delay(ms) {
  return new Promise((resolve, reject) => {
    if (ms < 0) {
      reject(new Error('Delay must be positive'));
    } else {
      setTimeout(() => resolve(`Delayed by ${ms}ms`), ms);
    }
  });
}

delay(1000)
  .then(message => console.log(message))
  .catch(err => console.error(err));

Async/Await

Async/await is syntactic sugar over promises, making asynchronous code read like synchronous code. It's the recommended approach in modern Node.js.

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

async function processFiles() {
  try {
    const data = await fs.readFile('data.txt', 'utf8');
    console.log('File content:', data);
    await fs.writeFile('output.txt', data);
    console.log('Successfully wrote to output.txt');
  } catch (err) {
    console.error('Error:', err);
  }
}

processFiles();

Parallel Execution

async function readMultipleFiles() {
  const files = ['file1.txt', 'file2.txt', 'file3.txt'];
  const promises = files.map(f => fs.readFile(f, 'utf8'));
  const results = await Promise.all(promises);
  return results;
}

readMultipleFiles().then(console.log);

Error Handling in Async Code

// Global unhandled promise rejection handler
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
Exercise: Write an async function that reads three files in parallel, concatenates their contents, and writes the result to a new file. Handle errors appropriately.