Notifications

No notifications

/Phase 1

Async Patterns

Asynchronous JavaScript in Node.js

Node.js is single-threaded but handles thousands of concurrent operations through asynchronous programming.

The Evolution of Async

PatternEraReadability
Callbacks2009+⭐ (callback hell)
Promises2015 (ES6)⭐⭐⭐
async/await2017 (ES8)⭐⭐⭐⭐⭐

Callback Hell 😱

getUser(id, (err, user) => {
  getPosts(user.id, (err, posts) => {
    getComments(posts[0].id, (err, comments) => {
      // 3 levels deep... and growing
    });
  });
});

Promises — Chainable 🔗

getUser(id)
  .then(user => getPosts(user.id))
  .then(posts => getComments(posts[0].id))
  .then(comments => console.log(comments))
  .catch(err => console.error(err));

async/await — Clean & Readable ✨

async function loadData(id) {
  try {
    const user = await getUser(id);
    const posts = await getPosts(user.id);
    const comments = await getComments(posts[0].id);
    console.log(comments);
  } catch (err) {
    console.error(err);
  }
}

On this page

Detailed Theory

Why Async Is Everything in Node

A backend spends most of its time waiting — for the database, for an API, for a file. Node has a single thread, so if it blocked while waiting, your whole server would freeze.

The rule: never block, always defer. Node hands the wait off to the OS / a thread pool, runs other requests, and resumes you when the answer arrives. The mechanism that schedules all this is the event loop.

Three Generations of Async Code

// 1. Callbacks (the original — "callback hell")
fs.readFile('a.json', (err, data) => {
  if (err) return console.error(err);
  fs.writeFile('b.json', data, (err) => {
    if (err) return console.error(err);
    console.log('done');
  });
});

// 2. Promises fs.promises.readFile('a.json') .then((data) => fs.promises.writeFile('b.json', data)) .then(() => console.log('done')) .catch(console.error);

// 3. async / await — what you should write today try { const data = await fs.promises.readFile('a.json'); await fs.promises.writeFile('b.json', data); console.log('done'); } catch (err) { console.error(err); }

All three describe the *same* operation. async/await is just nicer syntax over Promises.

Promises in 60 Seconds

A Promise is an object that will eventually be fulfilled with a value or rejected with an error.

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

await wait(1000); // pauses this function for 1 s — without blocking the event loop

async / await

async function getUser(id) {
  const res = await fetch(/api/users/${id});
  if (!res.ok) throw new Error('not found');
  return res.json();
}

  • async makes a function return a Promise automatically.
  • await pauses *this* function until the Promise resolves; everything else keeps running.
  • Throwing inside an async function rejects the returned Promise — handle it with try/catch.

Beginner Mistakes to Skip

1. Forgetting await. const u = getUser(1) gives you a Promise, not a user. u.name is undefined. 2. await in a loop when calls are independent. for + await runs them one after another. Use Promise.all for parallelism. 3. No try/catch. A rejected Promise becomes an *unhandled rejection* and may crash newer Node versions. 4. async for no reason. If you don't use await, the function doesn't need to be async. 5. Mixing .then and await in the same flow. Pick a style per function. 6. CPU-heavy work inside an async fn. await doesn't make synchronous CPU loops non-blocking. Use Worker Threads.

Intermediate: Sequential vs Parallel

// Sequential (slow when calls are independent)
const u = await fetchUsers();    // 200 ms
const p = await fetchPosts();    // 300 ms
const c = await fetchComments(); // 150 ms
// total ≈ 650 ms

// Parallel — start all, wait for all const [u, p, c] = await Promise.all([ fetchUsers(), fetchPosts(), fetchComments(), ]); // total ≈ 300 ms (the slowest one)

Use sequential only when later calls *depend* on earlier results.

Intermediate: Promise Combinators

// All must succeed; first rejection wins
await Promise.all([a, b, c]);

// Wait for all results — never throws const results = await Promise.allSettled([a, b, c]); // each result: { status: 'fulfilled', value } | { status: 'rejected', reason }

// First to *resolve* wins (others ignored unless all reject) await Promise.any([a, b, c]);

// First to settle (resolve or reject) — useful for timeouts await Promise.race([fetch('/slow'), wait(2000).then(() => { throw new Error('timeout'); })]);

Intermediate: AbortController — Cancelling Async Work

const ac = new AbortController();
setTimeout(() => ac.abort(), 5000); // cancel after 5 s

const res = await fetch('/api/big', { signal: ac.signal });

Almost every modern Node API (fetch, fs.readFile, timers, streams) accepts an AbortSignal. Essential for cancelling stale requests in long-running services.

Intermediate: Error Handling Pattern

async function handler(req, res, next) {
  try {
    const data = await db.users.findById(req.params.id);
    if (!data) return res.status(404).json({ error: 'not found' });
    res.json(data);
  } catch (err) {
    next(err); // hand off to a central error middleware
  }
}

Never console.log and swallow — propagate the error so a single place decides the HTTP response.

Intermediate: The Event Loop in One Picture

When all synchronous code finishes, Node loops through phases:

┌───────────────┐
│  timers       │  setTimeout / setInterval callbacks
├───────────────┤
│  pending I/O  │  some system callbacks
├───────────────┤
│  poll         │  most I/O callbacks (fs reads, sockets…)
├───────────────┤
│  check        │  setImmediate callbacks
├───────────────┤
│  close        │  socket close events
└───────────────┘
      ↑
  (between every phase, the microtask queue drains:
   Promise .then, queueMicrotask, process.nextTick)

Microtasks (Promises) run before the next macrotask. That's why this prints 1, 4, 3, 2:

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

Advanced: process.nextTick vs queueMicrotask vs setImmediate

  • process.nextTick(cb) — runs before any other microtask. Highest priority, easy to starve the loop.
  • queueMicrotask(cb) — same queue as Promise callbacks.
  • setImmediate(cb) — runs in the *next* loop iteration's check phase.
  • setTimeout(cb, 0) — at least one full loop iteration later, in the timers phase.
Day-to-day you only need Promises. The rest are escape hatches for library authors.

Advanced: Async Iteration & Generators

import { createReadStream } from 'node:fs';

// for-await-of works on any async iterable (streams, paginated APIs…) for await (const chunk of createReadStream('big.txt', 'utf8')) { process.stdout.write(chunk); }

// Build your own paginator async function* pages(url) { let next = url; while (next) { const res = await fetch(next).then(r => r.json()); yield res.items; next = res.nextUrl; } }

for await (const items of pages('/api/users?page=1')) { console.log(items.length); }

Advanced: Concurrency Limits

Promise.all runs everything at once — fine for 10 things, terrible for 10 000 (you'll exhaust file handles or rate-limit the API). Cap concurrency:

import pLimit from 'p-limit';
const limit = pLimit(5); // at most 5 in flight

const results = await Promise.all( urls.map((u) => limit(() => fetch(u))) );

Advanced: Safety Nets for Unhandled Rejections

process.on('unhandledRejection', (reason) => {
  console.error('UNHANDLED', reason);
  // log + maybe gracefully exit
});

process.on('uncaughtException', (err) => { console.error('FATAL', err); process.exit(1); });

In newer Node, unhandled rejections crash the process by default — these handlers exist so you log first.

Practice Path

1. Convert a callback-style fs.readFile snippet to fs/promises + async/await. 2. Fetch three independent URLs sequentially and time it; switch to Promise.all and compare. 3. Use AbortController to cancel a fetch that runs longer than 2 s. 4. Predict the output of: console.log(1); setTimeout(() => console.log(2)); Promise.resolve().then(() => console.log(3)); console.log(4); — then run it.