Skip to content

Node.js Async Patterns — Complete Guide to Callbacks, Promises, and Async-Await

DodaTech Updated 2026-06-28 4 min read

In this tutorial, you will learn about Node.js Async Patterns. We cover key concepts, practical examples, and best practices to help you master this topic.

Node.js async patterns evolved from callbacks through Promises to async-await, each providing cleaner syntax for managing asynchronous operations and avoiding callback nesting.

What You'll Learn

By the end of this tutorial, you'll write asynchronous Node.js code using callbacks, Promises, and async-await patterns, handle errors correctly, manage concurrency, and choose the right pattern.

Why Async Patterns Matter

JavaScript is single-threaded. Every I/O operation must be asynchronous to avoid blocking. Choosing the right async pattern affects code readability, error handling, and maintainability.

Real-World Use

A data pipeline fetches from three APIs concurrently with Promise.all, transforms results through async functions, and handles timeouts with Promise.race for resilient data processing.

Async Patterns Path

flowchart LR
  A[Blocking vs Non-Blocking] --> B[Async Patterns]
  B --> C[Error Handling]
  C --> D[Streams]
  D --> E[Async Hooks]
  B --> F{You Are Here}
  style F fill:#f90,color:#fff

Callback Pattern

The original Node.js async pattern. Callbacks receive (err, result) following the error-first convention.

const fs = require("node:fs");
function readConfig(callback) {
  fs.readFile("/etc/config.json", "utf8", (err, data) => {
    if (err) return callback(err);
    try {
      const config = JSON.parse(data);
      callback(null, config);
    } catch (parseErr) {
      callback(parseErr);
    }
  });
}
readConfig((err, config) => {
  if (err) return console.error("Failed:", err.message);
  console.log("Config loaded:", config.host);
});

Promise Pattern

Promises provide .then() chaining and centralized error handling with .catch().

const fs = require("node:fs/promises");
function readConfig() {
  return fs.readFile("/etc/config.json", "utf8")
    .then((data) => JSON.parse(data))
    .catch((err) => {
      console.error("Config load failed:", err.message);
      return { host: "localhost", port: 3000 };
    });
}
readConfig().then((config) => {
  console.log("Config:", config);
});

Async-Await Pattern

async-await provides synchronous-looking syntax for asynchronous code, built on Promises.

const fs = require("node:fs/promises");
async function loadConfig() {
  try {
    const data = await fs.readFile("/etc/config.json", "utf8");
    return JSON.parse(data);
  } catch (err) {
    console.error("Using default config:", err.message);
    return { host: "localhost", port: 3000 };
  }
}
const config = await loadConfig();
console.log("Config loaded:", config);

Promise.all for Concurrency

Run multiple async operations in parallel and wait for all to complete.

async function fetchAllUsers(ids) {
  const promises = ids.map((id) => fetch(`https://api.example.com/users/${id}`));
  const responses = await Promise.all(promises);
  const users = await Promise.all(responses.map((r) => r.json()));
  return users;
}
const users = await fetchAllUsers([1, 2, 3]);
console.log("Users:", users.length);

Promise.race for Timeouts

Race an async operation against a timeout to enforce deadlines.

function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) => {
    setTimeout(() => reject(new Error("Operation timed out")), ms);
  });
  return Promise.race([promise, timeout]);
}
async function fetchWithTimeout(url) {
  const response = await withTimeout(fetch(url), 5000);
  return response.json();
}

Common Mistakes

1. Callback Hell (Pyramid of Doom)

Deeply nested callbacks make code unreadable. Use Promises or async-await to flatten chains.

2. Swallowing Promise Rejections

Promises without .catch() cause unhandledRejection. Always attach error handlers.

3. Sequential Instead of Parallel

Using multiple awaits sequentially when operations are independent. Use Promise.all for parallelism.

4. Forgetting to Return Promises in .then()

Non-returned promises in .then() chains break the chain. Always return the promise.

5. Mixing Callbacks and Promises

Use promisify (util.promisify) to convert callback-based functions to Promise-based for consistency.

Practice Questions

1. What is the error-first callback convention?

The first argument of a callback is an error (null if success), the second is the result data.

2. How does async-await improve over Promises?

It provides synchronous-looking syntax without .then() chains, making code more readable.

3. What is the difference between Promise.all and Promise.allSettled?

Promise.all rejects on first failure. Promise.allSettled waits for all to complete regardless of success or failure.

4. When should you use Promise.race?

For timeouts, cancellations, or when you need the fastest response from multiple sources.

5. Challenge: Implement a retry pattern with exponential backoff using async-await.

async function retry(fn, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt === maxRetries) throw err;
      const delay = Math.pow(2, attempt) * 100;
      await new Promise((r) => setTimeout(r, delay));
    }
  }
}

FAQ

What is the difference between microtask and macrotask?

Microtasks (Promise.then, process.nextTick) run before macrotasks (setTimeout, I/O). Microtasks have higher priority.

Should I always use async-await over Promises?

Use async-await for sequential flows. Promise.all/race are better for concurrency patterns.

How do I convert a callback function to a Promise?

Use util.promisify for Node.js style (err, result) callbacks.

What is the event loop's role in async patterns?

The event loop coordinates callback execution. Promises use microtask queue, timers use macrotask queue.

Can I cancel a Promise?

Not natively. Use AbortController (fetch) or third-party libraries like bluebird for cancellable promises.

Mini Project: Async Queue with Concurrency Control

Build an async queue that processes tasks with configurable concurrency.

class AsyncQueue {
  constructor(concurrency = 2) {
    this.concurrency = concurrency;
    this.queue = [];
    this.active = 0;
  }
  async add(task) {
    return new Promise((resolve, reject) => {
      this.queue.push({ task, resolve, reject });
      this.process();
    });
  }
  async process() {
    while (this.active < this.concurrency && this.queue.length) {
      const { task, resolve, reject } = this.queue.shift();
      this.active++;
      try {
        const result = await task();
        resolve(result);
      } catch (err) {
        reject(err);
      } finally {
        this.active--;
        this.process();
      }
    }
  }
}

What's Next

Node.js Error Handling Node.js Streams Node.js Async Hooks

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro