← All Chapters

Chapter 5

Asynchronous Control Flow Patterns with Promises and Async/Await

Pages 123-157nodejspromisesasync-awaitconcurrencyerror-handling
Ch 1Ch 2Ch 3Ch 4Ch 5Ch 6Ch 7Ch 8Ch 9Ch 10Ch 11Ch 12Ch 13

Chapter 5: Asynchronous Control Flow Patterns with Promises and Async/Await

Summary

This chapter introduces Promises and async/await as the modern approach to asynchronous control flow in Node.js, replacing the callback patterns from Chapter 4. A Promise is an object representing the eventual result (or failure) of an async operation. It provides a cleaner abstraction for chaining operations, handling errors, and composing concurrent tasks.

The chapter progresses from Promise fundamentals (states, the fact that .then() returns a new promise enabling chaining, guaranteed async execution preventing Zalgo) through promisification (the manual implementation and util.promisify), then to async/await as syntactic sugar. The same control flow patterns from Chapter 4 (sequential, parallel, limited parallel) are revisited with dramatically simpler implementations. Key pitfalls are covered: the return vs return await trap in try/catch blocks, and the forEach antipattern.

Key Concepts

Promise States

ℹ️Info

The Three States

  • Pending -- initial state, operation in progress
  • Fulfilled -- operation succeeded, promise has a value
  • Rejected -- operation failed, promise has a reason (error)

Once a promise transitions from pending to fulfilled or rejected, it is settled and its state is immutable -- it can never change again.

         +--- fulfill(value) ---> FULFILLED
PENDING -+
         +--- reject(reason) ---> REJECTED

Promises/A+ and Thenables

The Promises/A+ specification defines interoperability rules. The key concept is duck typing: any object with a .then() method is a thenable. This lets Promise.resolve() convert third-party promise implementations into native promises.

// A thenable -- has .then(), so Promise.resolve() can wrap it
const thenable = {
  then(onFulfilled) {
    setTimeout(() => onFulfilled(42), 100)
  }
}

const p = Promise.resolve(thenable)
p.then(value => console.log(value)) // 42

The Promise API

ℹ️Info

Constructor

const promise = new Promise((resolve, reject) => {
  // async work here
  if (success) resolve(value)
  else reject(new Error('failed'))
})

Static methods:

MethodBehavior
Promise.resolve(val)Returns a promise fulfilled with val (or adopts if val is thenable)
Promise.reject(err)Returns a promise rejected with err
Promise.all(iterable)Resolves when ALL fulfill, rejects on FIRST rejection (fail-fast)
Promise.allSettled(iterable)Waits for ALL to settle, returns {status, value/reason} objects
Promise.race(iterable)Settles with the first promise that settles (fulfills or rejects)

Instance methods:

MethodBehavior
.then(onFulfilled, onRejected)Synchronously returns a new promise; enables chaining
.catch(onRejected)Sugar for .then(undefined, onRejected)
.finally(onFinally)Runs regardless of outcome; doesn't receive value; passes through result

Chaining and Error Propagation

💡Tip

Key Insight .then() synchronously returns a NEW promise. Whatever you return from the handler becomes the resolution value of that new promise. If you throw, the new promise rejects. Errors propagate down the chain until a .catch() handles them.

fetch('/api/user')
  .then(res => res.json())          // returns parsed object
  .then(user => fetch(`/api/posts/${user.id}`))  // returns new promise
  .then(res => res.json())
  .then(posts => console.log(posts))
  .catch(err => console.error(err)) // catches ANY error in the chain

Errors thrown inside any .then() callback automatically reject the returned promise and propagate down until caught:

Promise.resolve('hello')
  .then(val => {
    throw new Error('oops')  // auto-rejects the returned promise
  })
  .then(val => {
    console.log('skipped')   // never runs -- chain is in rejected state
  })
  .catch(err => {
    console.log(err.message) // 'oops'
    return 'recovered'       // catch returns a value, chain continues fulfilled
  })
  .then(val => {
    console.log(val)         // 'recovered'
  })

Guaranteed Async Execution (No Zalgo)

⚠️Warning

Critical Property Promise callbacks are always executed asynchronously (as microtasks), even if the promise is already resolved. This prevents the "Zalgo" problem from Chapter 3 -- the inconsistency of sometimes-sync, sometimes-async behavior.

console.log('1')
Promise.resolve('2').then(val => console.log(val))
console.log('3')
// Output: 1, 3, 2  (the .then() callback always runs asynchronously)

Creating a Promise: The delay() Example

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

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

Promisification

Converting a callback-based API to return promises. The book shows the manual implementation first, then Node's built-in util.promisify:

Manual promisification pattern:

function promisify(callbackBasedFn) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      callbackBasedFn(...args, (err, result) => {
        if (err) return reject(err)
        resolve(result)
      })
    })
  }
}

Using util.promisify:

import { promisify } from 'util'
import { readFile } from 'fs'

const readFilePromise = promisify(readFile)
const data = await readFilePromise('file.txt', 'utf8')

Sequential Execution with Promises

Using promise chains -- dynamic chaining with reduce replaces the recursive iterate() pattern from Chapter 4:

const tasks = [task1, task2, task3]

const result = tasks.reduce(
  (prev, task) => prev.then(() => task()),
  Promise.resolve()
)

Parallel Execution with Promise.all

const urls = ['url1', 'url2', 'url3']
const results = await Promise.all(urls.map(url => fetch(url)))

Limited Parallel Execution (TaskQueue)

The promise-based TaskQueue from the book:

class TaskQueue {
  constructor(concurrency) {
    this.concurrency = concurrency
    this.running = 0
    this.queue = []
  }

  runTask(task) {
    return new Promise((resolve, reject) => {
      this.queue.push({ task, resolve, reject })
      this._next()
    })
  }

  _next() {
    while (this.running < this.concurrency && this.queue.length) {
      const { task, resolve, reject } = this.queue.shift()
      this.running++
      task()
        .then(resolve, reject)
        .finally(() => {
          this.running--
          this._next()
        })
    }
  }
}

Async/Await

ℹ️Info

The Two Rules

  1. async functions always return a promise
  2. await suspends execution until the awaited promise settles
async function loadUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`)
    const user = await res.json()
    return user  // wrapped in a fulfilled promise
  } catch (err) {
    console.error('Failed to load user:', err)
    throw err    // re-rejects the returned promise
  }
}

The "return" vs "return await" Trap

⚠️Warning

Gotcha Inside a try/catch block, return somePromise() does not catch rejections. You must use return await somePromise() to ensure the catch block handles errors.

// BUG: rejection is NOT caught
async function buggy() {
  try {
    return dangerousOperation()  // promise passes through uncaught
  } catch (err) {
    console.log('This never runs if dangerousOperation rejects')
  }
}

// CORRECT: rejection IS caught
async function correct() {
  try {
    return await dangerousOperation()  // await inside try = caught
  } catch (err) {
    console.log('Caught:', err.message)
  }
}

The forEach Antipattern

🔴Danger

Antipattern Never use Array.forEach() with async callbacks. forEach ignores the returned promise, so iterations fire concurrently and the code after forEach continues immediately without waiting.

// WRONG: all iterations fire at once, no waiting
items.forEach(async (item) => {
  await processItem(item)  // forEach doesn't await this
})
console.log('This runs BEFORE items are processed!')

// CORRECT: sequential
for (const item of items) {
  await processItem(item)
}

// CORRECT: parallel
await Promise.all(items.map(item => processItem(item)))

Sequential Execution with Async/Await

async function processSequentially(items) {
  const results = []
  for (const item of items) {
    results.push(await processItem(item))
  }
  return results
}

Parallel Execution with Async/Await

async function fetchAll(urls) {
  // Start all fetches (no await in map = all run concurrently)
  const promises = urls.map(url => fetch(url))
  // Wait for all to complete
  return Promise.all(promises)
}

Mind Map

Zalgo from Chapter 3 -- always async

  • The TaskQueue evolves the limited concurrency queue from Chapter 4
  • The sequential reduce pattern replaces Ch4's recursive iterate()

18 quiz · 24 cards · 2 exercises · Ch 5 of 13