NODE.JS DEVELOPMENT:Day 7: Async Patterns — Callbacks, Promises, and Async/Await

Mastering day 7: async patterns — callbacks, promises, and async/await concepts and implementation.

Async Patterns in Node.js

Node.js is single-threaded but handles I/O concurrently through its event loop. Understanding async patterns is essential for writing performant Node.js applications.

The Event Loop

Node.js processes async operations in phases:

  1. timers: setTimeout and setInterval callbacks
  2. I/O callbacks: most async I/O callbacks
  3. idle, prepare: internal
  4. poll: new I/O events (blocking if queue empty and no timers)
  5. check: setImmediate callbacks
  6. close callbacks: socket.on('close', ...)
console.log('1: sync');

setTimeout(() => console.log('2: setTimeout'), 0);
setImmediate(() => console.log('3: setImmediate'));
Promise.resolve().then(() => console.log('4: microtask'));

console.log('5: sync');

// Output order: 1 → 5 → 4 → 2 → 3
// Microtasks (Promises) run before the next event loop phase

Callbacks — The Original Pattern

const fs = require('fs');

// Error-first callback convention
fs.readFile('data.json', 'utf8', (err, data) => {
  if (err) {
    console.error('Failed to read:', err.message);
    return;
  }
  const json = JSON.parse(data);
  
  // Callback hell — nested callbacks are hard to read
  fs.writeFile('output.json', JSON.stringify(json, null, 2), (err) => {
    if (err) {
      console.error('Failed to write:', err.message);
      return;
    }
    console.log('Done!');
  });
});

Promises — Composable Async

const fs = require('fs').promises;  // Node 10+ built-in promise API

function processFile(inputPath, outputPath) {
  return fs.readFile(inputPath, 'utf8')
    .then(data => JSON.parse(data))
    .then(json => ({ ...json, processed: true }))
    .then(result => JSON.stringify(result, null, 2))
    .then(output => fs.writeFile(outputPath, output))
    .then(() => console.log('Done!'));
}

// Promise.all — run in parallel, wait for all
Promise.all([
  fetch('https://api.example.com/users'),
  fetch('https://api.example.com/posts'),
]).then(([usersRes, postsRes]) => {
  return Promise.all([usersRes.json(), postsRes.json()]);
}).then(([users, posts]) => {
  console.log(users, posts);
});

Async/Await — The Modern Standard

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

async function processFile(inputPath, outputPath) {
  try {
    const data = await fs.readFile(inputPath, 'utf8');
    const json = JSON.parse(data);
    const result = { ...json, processed: true };
    await fs.writeFile(outputPath, JSON.stringify(result, null, 2));
    return result;
  } catch (err) {
    if (err.code === 'ENOENT') throw new Error(`File not found: ${inputPath}`);
    throw err;  // Re-throw unexpected errors
  }
}

// Parallel execution with async/await
async function fetchDashboardData(userId) {
  const [user, posts, notifications] = await Promise.all([
    fetchUser(userId),
    fetchPosts(userId),
    fetchNotifications(userId),
  ]);
  return { user, posts, notifications };
}

// Sequential when order matters
async function onboardUser(data) {
  const user = await createUser(data);           // Must happen first
  const profile = await createProfile(user.id);  // Needs user.id
  await sendWelcomeEmail(user.email);             // Can be parallel with profile creation
  return { user, profile };
}

Common Async Patterns

Retry with exponential backoff

async function withRetry(fn, maxAttempts = 3, baseDelayMs = 300) {
  let lastError;
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastError = err;
      if (attempt < maxAttempts) {
        const delay = baseDelayMs * Math.pow(2, attempt - 1);  // 300, 600, 1200ms
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }
  throw lastError;
}

const data = await withRetry(() => fetch('https://api.example.com/data').then(r => r.json()));

Rate limiting with a queue

class RateLimitedQueue {
  constructor(requestsPerSecond) {
    this.interval = 1000 / requestsPerSecond;
    this.queue = [];
    this.running = false;
  }

  async add(fn) {
    return new Promise((resolve, reject) => {
      this.queue.push({ fn, resolve, reject });
      if (!this.running) this.run();
    });
  }

  async run() {
    this.running = true;
    while (this.queue.length > 0) {
      const { fn, resolve, reject } = this.queue.shift();
      try { resolve(await fn()); } catch (e) { reject(e); }
      await new Promise(r => setTimeout(r, this.interval));
    }
    this.running = false;
  }
}

const queue = new RateLimitedQueue(5);  // Max 5 requests/second
await queue.add(() => fetch('https://api.example.com/item/1'));

Stream processing for large files

const { createReadStream, createWriteStream } = require('fs');
const { Transform } = require('stream');
const readline = require('readline');

async function processLargeCSV(inputPath, outputPath) {
  const rl = readline.createInterface({ input: createReadStream(inputPath) });
  const out = createWriteStream(outputPath);

  out.write('id,name,processed\n');  // Header

  for await (const line of rl) {
    const [id, name] = line.split(',');
    const processed = name?.trim().toUpperCase();
    out.write(`${id},${processed},true\n`);
  }
  
  out.end();
  console.log('Processing complete');
}

// Streams handle gigabyte files without loading into memory
await processLargeCSV('10gb-file.csv', 'output.csv');

Hands-on Examples

Promise Combinators — allSettled, race, any

// Promise.all — fail fast on any rejection
const results = await Promise.all([p1, p2, p3]);

// Promise.allSettled — wait for all, regardless of failures
const settled = await Promise.allSettled([p1, p2, p3]);
settled.forEach(result => {
  if (result.status === 'fulfilled') console.log('OK:', result.value);
  else console.log('ERR:', result.reason);
});

// Promise.race — resolve/reject with first settled
const timeout = new Promise((_, reject) =>
  setTimeout(() => reject(new Error('Timeout')), 5000));
const result = await Promise.race([fetch(url), timeout]);

// Promise.any — resolve with first fulfilled (ignores rejections)
const fastestMirror = await Promise.any([
  fetch('https://mirror1.example.com/file'),
  fetch('https://mirror2.example.com/file'),
  fetch('https://mirror3.example.com/file'),
]);

allSettled is best for batch operations where partial success is OK. race is ideal for timeouts. any is perfect for trying multiple sources.