Asynchronous Programming

1. Introduction ⏳

Asynchronous programming is the heart of Node.js. Because the runtime is single-threaded, it relies on non-blocking patterns — callbacks, promises, and async/await — to handle I/O and timers without freezing execution. This tutorial covers every major async pattern and how they interact with the event loop.

2. Synchronous vs Asynchronous Programming 🔄

Synchronous code executes line by line, each statement waiting for the previous one to finish. Asynchronous code allows an operation to be scheduled and continue later, letting the program do other work in the meantime.

AspectSynchronousAsynchronous
ExecutionBlocking, sequentialNon-blocking, event-driven
Examplefs.readFileSync()fs.readFile()
ScalabilityPoor under concurrencyExcellent under concurrency

3. Callbacks 📞

3.1 Callback Patterns

A callback is a function passed as an argument to be invoked later, once an operation completes. Node's convention is error-first callbacks: the first parameter is always error (or null), followed by the result.

error-first-callback.js

const fs = require('node:fs');

fs.readFile('./data.txt', 'utf-8', (err, data) => {
  if (err) {
    console.error('Failed to read file:', err.message);
    return;
  }
  console.log(data);
});

3.2 Callback Hell

Callback hell (or the "pyramid of doom") happens when multiple asynchronous operations are nested inside each other's callbacks, producing deeply indented, hard-to-follow code.

callback-hell.js

getUser(userId, (err, user) => {
  if (err) return handleError(err);
  getOrders(user.id, (err, orders) => {
    if (err) return handleError(err);
    getOrderDetails(orders[0].id, (err, details) => {
      if (err) return handleError(err);
      console.log(details); // deeply nested!
    });
  });
});

Warning

Callback hell makes error handling repetitive and control flow hard to trace. Promises and async/await largely solve this.

4. Promises 🤝

A Promise represents the eventual result (or failure) of an asynchronous operation. It exists in one of three states: pending, fulfilled, or rejected.

promise-basics.js

function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

delay(1000).then(() => console.log('1 second passed'));

4.1 Promise Chaining

Each .then() returns a new Promise, allowing operations to be chained sequentially rather than nested.

promise-chaining.js

getUser(userId)
  .then((user) => getOrders(user.id))
  .then((orders) => getOrderDetails(orders[0].id))
  .then((details) => console.log(details))
  .catch((err) => console.error('Something failed:', err.message));

4.2 Promise Error Handling

A single .catch() at the end of a chain catches any rejection from earlier in the chain, since errors propagate through .then() calls until handled.

promise-error-handling.js

fetchData()
  .then(process)
  .catch((err) => console.error('Caught:', err.message))
  .finally(() => console.log('Cleanup runs regardless'));

Important

.finally() runs whether the Promise fulfilled or rejected — ideal for cleanup like closing a connection.

5. async/await

5.1 async Functions

An async function always returns a Promise, even if you return a plain value — it gets automatically wrapped.

async-function.js

async function getGreeting() {
  return 'Hello!'; // implicitly wrapped as Promise.resolve('Hello!')
}

5.2 await

await pauses execution within an async function until the awaited Promise settles, unwrapping its resolved value or throwing its rejection as a regular Error.

await-example.js

async function loadUser(id) {
  try {
    const user = await getUser(id);
    const orders = await getOrders(user.id);
    return orders;
  } catch (err) {
    console.error('Failed to load user data:', err.message);
    throw err;
  }
}

Tip

async/await is syntactic sugar over Promises — it doesn't change the underlying asynchronous model, just makes the code read sequentially.

6. Promise Combinators 🎯

6.1 Promise.all

Runs promises concurrently and resolves when all succeed — or rejects immediately if any fails.

promise-all.js

const [user, posts, comments] = await Promise.all([
  getUser(id),
  getPosts(id),
  getComments(id),
]);

6.2 Promise.allSettled

Waits for all promises to settle regardless of outcome, returning an array of { status, value | reason } objects — useful when partial failures are acceptable.

promise-allsettled.js

const results = await Promise.allSettled([
  fetchFromAPI1(),
  fetchFromAPI2(),
]);

results.forEach((r) => {
  if (r.status === 'fulfilled') console.log(r.value);
  else console.error(r.reason);
});

6.3 Promise.race

Resolves or rejects as soon as the first promise settles — commonly used to implement timeouts.

promise-race.js

function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error('Timed out')), ms)
  );
  return Promise.race([promise, timeout]);
}

6.4 Promise.any

Resolves as soon as the first promise fulfills, ignoring rejections unless all reject (throwing an AggregateError).

promise-any.js

const fastestMirror = await Promise.any([
  fetch('https://mirror1.example.com/data'),
  fetch('https://mirror2.example.com/data'),
]);

7. Timers ⏰

7.1 setTimeout

settimeout.js

const timer = setTimeout(() => console.log('Fired after 1s'), 1000);
clearTimeout(timer); // cancel before it fires

7.2 setInterval

setinterval.js

const interval = setInterval(() => console.log('tick'), 1000);
setTimeout(() => clearInterval(interval), 5000); // stop after 5s

7.3 setImmediate

setImmediate() schedules a callback to run in the check phase of the event loop, immediately after the current poll phase completes.

setimmediate.js

setImmediate(() => console.log('Runs after I/O events in this cycle'));

7.4 process.nextTick

process.nextTick() queues a callback to run before the event loop continues to any phase — even before Promise microtasks. Overuse can starve the event loop of I/O.

nexttick.js

process.nextTick(() => console.log('Runs before microtasks and timers'));

Danger

Recursive process.nextTick() calls can starve the event loop entirely, preventing I/O from ever being processed.

7.5 queueMicrotask

queueMicrotask() schedules a callback on the same microtask queue used by Promise callbacks — it runs after process.nextTick callbacks but before macrotasks.

queuemicrotask.js

queueMicrotask(() => console.log('Runs as a microtask'));

8. Event Loop Integration 🔁

Understanding the ordering of these mechanisms is essential for predicting execution order in complex async code.

Execution Priority (highest first)
process.nextTick queue
Microtask queue (Promises, queueMicrotask)
Timers phase (setTimeout, setInterval)
Poll phase (I/O callbacks)
Check phase (setImmediate)
Close callbacks phase

Example

Between every phase transition, Node fully drains both the nextTick queue and the microtask queue before moving on.

9. Async Iterators 🔂

Async iterators let you use for await...of to consume values that arrive asynchronously — commonly used with streams.

async-iterator.js

const fs = require('node:fs');

async function readLines(filePath) {
  const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
  for await (const chunk of stream) {
    console.log('Chunk:', chunk);
  }
}

10. AbortController 🛑

AbortController provides a standard way to cancel asynchronous operations — like fetch requests or long-running tasks — via a shared AbortSignal.

abort-controller.js

const controller = new AbortController();
const { signal } = controller;

setTimeout(() => controller.abort(), 3000); // cancel after 3s

try {
  const response = await fetch('https://api.example.com/data', { signal });
  console.log(await response.json());
} catch (err) {
  if (err.name === 'AbortError') console.log('Request was aborted');
}

11. Retry Strategies 🔄

Transient failures (network blips, rate limits) can often be handled with a retry strategy, typically combined with exponential backoff to avoid overwhelming a struggling service.

retry-with-backoff.js

async function retry(fn, attempts = 3, delayMs = 500) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      if (i === attempts - 1) throw err;
      await new Promise((r) => setTimeout(r, delayMs * 2 ** i));
    }
  }
}

12. Concurrency Patterns 🧵

Running too many async operations at once can overwhelm downstream services or exhaust the thread pool. A common pattern is limiting concurrency using a semaphore-like queue.

concurrency-limit.js

async function mapWithConcurrency(items, limit, fn) {
  const results = [];
  let index = 0;

  async function worker() {
    while (index < items.length) {
      const current = index++;
      results[current] = await fn(items[current]);
    }
  }

  await Promise.all(Array.from({ length: limit }, worker));
  return results;
}

13. Performance Optimization ⚡

  • Prefer Promise.all over sequential await calls when operations are independent.
  • Avoid excessive process.nextTick recursion, which can starve the event loop.
  • Limit concurrency when calling rate-limited external APIs.
  • Use AbortController to cancel work that's no longer needed, freeing resources sooner.

14. Best Practices ✅

  • Always attach a .catch() or wrap await in try/catch — never leave a Promise rejection unhandled.
  • Use Promise.all for independent operations instead of awaiting them one by one.
  • Reach for async/await over raw .then() chains for readability in most application code.
  • Use AbortController for any operation with a deadline or cancellation requirement.

15. Common Mistakes ⚠️

  • Awaiting independent async calls sequentially instead of using Promise.all, hurting performance.
  • Forgetting await inside an async function, causing a Promise to be used where a resolved value was expected.
  • Mixing callbacks and Promises inconsistently within the same codebase.
  • Not handling the 'unhandledRejection' process event during development.
  • Using Promise.all when partial failures should be tolerated — Promise.allSettled is usually more appropriate there.

16. Frequently Asked Questions ❓

Question

What's the difference between setImmediate and setTimeout(fn, 0)?

Answer

Inside an I/O callback, setImmediate() always runs before a setTimeout(fn, 0). Outside an I/O cycle, their order is not guaranteed.

Question

Does await block the entire event loop?

Answer

No — await only pauses the current async function. The event loop keeps processing other work (timers, I/O, other requests) while it waits.

Question

Which runs first: process.nextTick or a resolved Promise.then?

Answer

process.nextTick callbacks run before the Promise microtask queue is processed.

17. Summary 📝

Summary

Node's asynchronous model evolved from error-first callbacks to Promises and finally async/await, each layer improving readability without changing the underlying non-blocking mechanics. Combinators like Promise.all and Promise.race, timer functions, and tools like AbortController round out a toolkit for writing efficient, resilient asynchronous Node.js code.