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
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
Constructor
const promise = new Promise((resolve, reject) => {
// async work here
if (success) resolve(value)
else reject(new Error('failed'))
})
Static methods:
| Method | Behavior |
|---|---|
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:
| Method | Behavior |
|---|---|
.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
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)
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
The Two Rules
asyncfunctions always return a promiseawaitsuspends 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
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
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()