NodeJSAsync Control-Flow Patterns

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 await

Each step needs the previous result

Sum of all

Promise.all

Steps are independent, all must succeed

Max of all

Promise.allSettled

Independent, want every outcome

Max of all

Bounded concurrency

Many items, limited resources

Between the two

JS
// 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:

JS
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()))
Unbounded parallelism is a production hazard
`await Promise.all(tenThousandItems.map(fetchOne))` opens ten thousand simultaneous connections — you'll hit file-descriptor limits, rate limits, or run out of memory. Always bound concurrency for large or unknown-size collections. For ergonomics, the `p-limit` package wraps this pattern cleanly.
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:

JS
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
Modern Node has `AbortSignal.timeout`
Rather than racing a dangling timer (which keeps running after the operation wins), pass an `AbortSignal.timeout(ms)` to APIs that accept a signal (`fetch`, `fs`, many others). It cancels the actual work and cleans up — a true timeout, not just an ignored result. Combine with `AbortController` for manual cancellation.

JS
// 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:

JS
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()))
Only retry *idempotent*, *transient* failures
Retrying a non-idempotent operation (a payment, a `POST` that creates a record) can double-charge or duplicate data. And retrying a permanent error (`404`, validation failure) just wastes time. Retry only safe-to-repeat operations, only on transient errors (timeouts, 5xx, network blips), and add jitter to avoid synchronized retry storms.
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:

JS
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
Never leave a promise floating without a catch
If you intentionally don't await a promise ("fire and forget"), you must still attach a `.catch` — an unhandled rejection can crash the process. Make the intent explicit so it's not mistaken for a forgotten `await`.

JS
// ✗ 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 raw Promise.all.

  • Slow dependency → timeout via AbortSignal.timeout.

  • Flaky transient failures → retry with exponential backoff (idempotent only).

  • Huge/unbounded data → for await with backpressure, not load-it-all.

Section complete
That wraps up Asynchronous Node.js & the Event Loop. Next we put it to work against the disk: [The File System (fs) Module](/nodejs/fs-intro).