Asynchronous Patterns

Difficulty

Answer:

Callbacks are the original async pattern in Node — you pass a function that runs when the operation finishes.

Error-first convention: Node core callbacks take (err, ...results). Always check err first:

fs.readFile('data.txt', 'utf8', (err, data) => {
  if (err) return handleError(err); // error first
  console.log(data);
});

The return on error prevents running the success path.

Callback hell — nesting async steps produces a rightward "pyramid of doom" that's hard to read and error-handle:

getUser(id, (err, user) => {
  if (err) return cb(err);
  getPosts(user.id, (err, posts) => {
    if (err) return cb(err);
    getComments(posts[0].id, (err, comments) => {
      if (err) return cb(err);
      // ...deeper and deeper
    });
  });
});

Fixes:

  • Promises + chaining flatten the nesting.
  • async/await makes async code read like synchronous code.
try {
  const user = await getUser(id);
  const posts = await getPosts(user.id);
  const comments = await getComments(posts[0].id);
} catch (err) {
  handleError(err); // one place for errors
}

Tip: you can wrap a callback API with util.promisify to use it with async/await.

Answer:

States: a Promise is pending, then settles to fulfilled (with a value) or rejected (with a reason). Once settled it never changes.

Chaining: each .then returns a new promise, so you can transform values and funnel all errors to a single .catch:

fetchUser(id)
  .then(user => fetchPosts(user.id))
  .then(posts => render(posts))
  .catch(err => handle(err))   // catches any rejection above
  .finally(() => stopSpinner());

Combinators:

MethodSettles whenResultUse case
Promise.allall fulfilled, or first rejectionarray of values / first errorparallel work, all required
Promise.allSettledall settledarray of {status, value/reason}independent tasks, want every outcome
Promise.racefirst to settle (either way)that value/reasontimeouts, fastest wins
Promise.anyfirst fulfillment, or all rejectedfirst value / AggregateErrorfallbacks/redundancy
// Fetch three resources in parallel; fail if any fails
const [a, b, c] = await Promise.all([getA(), getB(), getC()]);

// Add a timeout to any promise
const result = await Promise.race([
  doWork(),
  new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 5000)),
]);

Gotchas:

  • Promise.all is fail-fast: one rejection rejects the whole thing (the others still run but their results are discarded). Use allSettled when you need every outcome.
  • Always attach a .catch (or wrap await in try/catch) — an unhandled rejection can crash the process.

Answer:

async/await lets you write promise-based code that reads sequentially:

async function loadDashboard(id) {
  try {
    const user = await getUser(id);
    const posts = await getPosts(user.id);
    return { user, posts };
  } catch (err) {
    // any rejection above lands here
    throw new Error('Failed to load dashboard', { cause: err });
  }
}
  • An async function always returns a Promise.
  • await suspends the function (not the thread) until the awaited promise settles; a rejection throws, caught by try/catch.

Converting callback APIs to promises:

  1. util.promisify for error-first callback functions:
const util = require('util');
const readFile = util.promisify(require('fs').readFile);
const data = await readFile('data.txt', 'utf8');
  1. Use built-in promise APIs where available:
const fs = require('fs/promises');
const data = await fs.readFile('data.txt', 'utf8');
  1. Manual wrapping for non-standard callbacks:
function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}
await delay(1000);

Common pitfalls:

  • Accidental serialization: awaiting in a loop runs items one-by-one. Use Promise.all with map for parallelism.
  • Missing await: forgetting it means you get a pending Promise instead of the value, and errors go unhandled.
  • forEach + async doesn't await — use for...of or Promise.all(map(...)).

Answer: EventEmitter (from the events module) is the foundation of Node's event-driven APIs — HTTP servers, streams, sockets, and the process object are all emitters.

Basic usage:

const { EventEmitter } = require('events');

class Order extends EventEmitter {}
const order = new Order();

order.on('paid', (amount) => console.log(`Received $${amount}`));
order.once('shipped', () => console.log('Shipped!')); // fires at most once

order.emit('paid', 99);   // triggers the 'paid' listener
order.emit('shipped');

Key methods:

  • .on(event, fn) / .addListener — subscribe.
  • .once(event, fn) — subscribe for a single occurrence.
  • .emit(event, ...args) — fire synchronously, in listener registration order.
  • .off(event, fn) / .removeListener — unsubscribe (important to avoid leaks).

When to use EventEmitter vs Promises:

  • Promise/async-await: a single future result (a DB query, one HTTP request).
  • EventEmitter: a stream of events over time (data chunks, connection events, progress updates) — one-to-many, repeated.

Gotchas:

  • Listeners run synchronously; a throwing listener can crash unless handled.
  • An 'error' event with no listener throws and can crash the process — always handle 'error'.
  • Adding many listeners triggers a memory-leak warning at 10 by default (emitter.setMaxListeners(n) to adjust); usually the warning means you forgot to remove listeners.

Answer:

Sequential — when each step depends on the previous, or you must limit load:

const results = [];
for (const id of ids) {
  results.push(await fetchItem(id)); // one at a time
}

Parallel — when operations are independent, run them together:

// Kick them all off, then wait for all
const results = await Promise.all(ids.map(id => fetchItem(id)));

This can be dramatically faster: three 100ms calls take ~100ms in parallel vs ~300ms sequentially.

Common mistakes:

  1. Accidental sequencing — awaiting independent calls one by one:
// ❌ Slow: b doesn't need a
const a = await getA();
const b = await getB();
// ✅ Fast:
const [a, b] = await Promise.all([getA(), getB()]);
  1. forEach doesn't await:
// ❌ Fires all, but the function returns before they finish
items.forEach(async (i) => { await save(i); });
// ✅ Wait for completion
await Promise.all(items.map(i => save(i)));
// ✅ Or truly sequential:
for (const i of items) { await save(i); }
  1. Unbounded parallelismPromise.all over 10,000 items opens 10,000 connections at once. Use a concurrency limit (e.g., p-limit, or process in batches) to protect downstream services and the libuv thread pool.

Rule of thumb: default to parallel for independent work (Promise.all), sequential only when there's a real dependency or a need to throttle.