Async Control-Flow Patterns
Knowing async/await is one thing; orchestrating real async work is another. Production code needs to run things in sequence or in parallel, cap concurrency, time out, retry on failure, and process streams of work without exhausting resources. This page collects the battle-tested patterns — built on plain promises, no libraries required.
Sequential vs parallel
Pattern | Use when | Total time |
|---|---|---|
Sequential | Each step needs the previous result | Sum of all |
| Steps are independent, all must succeed | Max of all |
| Independent, want every outcome | Max of all |
Bounded concurrency | Many items, limited resources | Between the two |
// Sequential — dependent chain const user = await getUser(id) const orders = await getOrders(user.id) // needs user.id // Parallel — independent const [profile, settings, notifications] = await Promise.all([ getProfile(id), getSettings(id), getNotifications(id), ])
Pattern: bounded concurrency
Firing thousands of operations at once via Promise.all exhausts sockets and memory. Process them in fixed-size batches so only N run concurrently:
async function mapLimit(items, limit, fn) {
const results = []
for (let i = 0; i < items.length; i += limit) {
const batch = items.slice(i, i + limit)
results.push(...await Promise.all(batch.map(fn)))
}
return results
}
// Fetch 1000 URLs, but only 10 in flight at a time:
const data = await mapLimit(urls, 10, (url) => fetch(url).then((r) => r.json()))Pattern: timeout with Promise.race
Race the real operation against a timer — whichever settles first wins. This caps how long you'll wait for a slow dependency:
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timed out')), ms))
return Promise.race([promise, timeout])
}
const data = await withTimeout(fetch(url), 5000) // reject if >5s// Cancels the request itself, not just the wait:
const res = await fetch(url, { signal: AbortSignal.timeout(5000) })Pattern: retry with backoff
Transient failures (a flaky network, a momentarily-overloaded service) often succeed on retry. Back off exponentially so you don't hammer a struggling server:
import { setTimeout as sleep } from 'node:timers/promises'
async function retry(fn, { attempts = 3, baseMs = 200 } = {}) {
let lastErr
for (let i = 0; i < attempts; i++) {
try {
return await fn()
} catch (err) {
lastErr = err
if (i < attempts - 1) await sleep(baseMs * 2 ** i) // 200, 400, 800ms
}
}
throw lastErr
}
const data = await retry(() => fetch(url).then((r) => r.json()))Pattern: process a stream of work safely
For huge or unbounded data, async iteration with for await processes items one at a time, applying natural backpressure — you never hold the whole dataset in memory:
import { createReadStream } from 'node:fs'
import { createInterface } from 'node:readline'
const rl = createInterface({ input: createReadStream('huge.log') })
for await (const line of rl) { // one line at a time, backpressured
await processLine(line) // waits before pulling the next
}Pattern: fire-and-forget — done right
// ✗ Floating promise — a rejection here is unhandled
logAnalytics(event)
// ✓ Explicitly fire-and-forget, errors still handled
void logAnalytics(event).catch((err) => console.error('analytics failed', err))Choosing a pattern
Dependent steps → sequential
await. Independent →Promise.all.Need every result despite failures →
Promise.allSettled.Many items → bounded concurrency (
mapLimit/p-limit), never rawPromise.all.Slow dependency → timeout via
AbortSignal.timeout.Flaky transient failures → retry with exponential backoff (idempotent only).
Huge/unbounded data →
for awaitwith backpressure, not load-it-all.