Callbacks, Promises and async/await

Asynchronous code evolved through three eras — callbacks, promises, async/await. The third reads like synchronous code but runs on the same event loop as lesson 19. This lesson teaches the current model and the failure modes that survive all three eras.

Promises: a handle for one future value

Three states, settled exactly once

A promise starts pending and settles once — fulfilled (has a value) or rejected (has an error). Settlement is final: a second resolve is a no-op. That guarantee is what makes chaining composable.

Promise states and how the chain routes fulfillment and rejection

Fulfillment flows through .then; rejection skips ahead to the nearest .catch.

Chaining transforms values; rejections skip ahead

fetchLike(2)
  .then((n) => n * 10)   // return a value → next .then receives it
  .then((n) => fetchLike(n)) // return a PROMISE → the chain waits for it
  .then((n) => console.log("chain result:", n))
  .catch((error) => console.log("caught:", error.message)); // covers EVERY step above

// Expected output:
// chain result: 31   (fetchLike(2)=3 → 3*10=30 → fetchLike(30)=31)

The four combinators, one job each

// p1, p2, p3 are promises.
Promise.all([p1, p2, p3]);      // all must fulfill — one rejection rejects everything
Promise.allSettled([p1, p2]);   // never rejects — reports every outcome
Promise.any([p1, p2]);          // first FULFILLED wins (rejections ignored)
Promise.race([p1, p2]);         // first SETTLED wins — even a rejection

async/await — sequential syntax, concurrent scheduling

The await keyword pauses the FUNCTION, not the thread

// SEQUENTIAL: 30ms + 40ms — the second waits for the first on purpose
const a = await fetchSlow(30);
const b = await fetchSlow(40); // starts only after a finished

// CONCURRENT: both requests are in flight immediately — total ≈ max, not sum
const [x, y] = await Promise.all([fetchSlow(30), fetchSlow(40)]);

// Expected timing (roughly):
// sequential ≈ 70ms · concurrent ≈ 40ms

Choosing between them is an architectural decision: depend on each other → sequential; independent → concurrent. The single most common performance bug in async code is await-ing in a loop what could be Promise.all-ed.

Errors: try/catch works — if you do not float the promise

async function loadProfile(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) throw new Error(`HTTP ${response.status}`); // lesson 17's trap
    return await response.json();
  } catch (error) {
    console.error("loadProfile failed:", error.message); // covers BOTH awaits
    return null;
  }
}

Contraexample — the floating promise: calling saveSettings(); without await (or .catch) detaches the entire chain: no try/catch can see its failures, and since Node 15 an unhandled rejection kills the process. Demo async/28_promises_lab.js shows the crash and the fix.

Practice: from pyramid to async/await

The task

Run async/27_event_loop_lab.js and async/28_promises_lab.js with node. Predict every log line before each runs. Then take this callback pyramid and flatten it into async/await with a try/catch — without changing what happens, only how it reads:

getUser(id, (err, user) => {
  if (err) return console.error(err);
  getPosts(user, (err, posts) => {
    if (err) return console.error(err);
    getComments(posts[0], (err, comments) => {
      if (err) return console.error(err); // three levels deep and counting
      console.log(comments.length);
    });
  });
});

The checklist

  • You can predict any mixed sync/timer/promise log order — and explain it with the two queues.
  • You know settlement is final, and one catch covers a whole chain.
  • You pick the combinator by its rejection behavior, not by habit.
  • You never leave a promise floating: every call is awaited or has a catch.