Siddhant Deval
Siddhant Deval
frontend11 min read

Event Loop & Advanced Async Concurrency: Promise Combinators & Async Generators

Promise.all is the wrong default for parallel work. Promise.allSettled, Promise.any, and Promise.race are purpose-built combinators for resilient async composition, and async generators unlock non-blocking dataset streaming that async/await alone cannot express.

Event Loop & Advanced Async Concurrency: Promise Combinators & Async Generators

The runtime is not a black box — and neither is concurrent JavaScript. If you write Promise.all([fetchUser(), fetchOrders(), fetchInventory()]) and one endpoint goes down, the entire operation fails. Not slowly, not gracefully — it rejects immediately, discarding the two successful responses you already paid for. This is the wrong tool. This article builds the full mental model for async composition: which combinator to reach for when, and how async generators give you something async/await fundamentally cannot — a lazy, pull-based data stream that processes paginated APIs and large datasets without loading everything into memory.

Architectural Note

This article assumes you understand the WHATWG Event Loop formal model: Macrotask → Microtask Checkpoint → Render. If you haven't read The JavaScript Event Loop: Macro → Micro → Render, read it first — it covers the scheduling architecture this article builds on. Part 3 starts at the composition layer.


1. The Event Loop Architecture — Composition Context

The call stack, microtask queue, and macrotask queue determine when your async callbacks execute. Promise resolution enqueues microtasks; setTimeout enqueues macrotasks. The full treatment is in the cross-referenced article. What matters for this article's scope:

  • Promise resolution is always a microtask — resolved Promises schedule their .then callbacks in the microtask queue, which drains fully before the next macrotask or render frame
  • queueMicrotask(fn) lets you enqueue your own microtasks explicitly — useful for deferring work until the current synchronous block finishes without waiting for a full macrotask cycle
  • The composition combinators (allSettled, any, race) all build on the same microtask scheduling — their semantics differ in which resolutions they propagate
JAVASCRIPT
// queueMicrotask — defer until current sync block finishes
console.log('A')
queueMicrotask(() => console.log('B'))
console.log('C')
// Output: A, C, B
// 'B' runs in the microtask checkpoint after the synchronous script macrotask completes

2. Promise Combinators — The Right Tool for the Job

Promise.all is the combinator most developers learn first and reach for exclusively. It has one severe limitation: it is fail-fast — if any input promise rejects, the combinator rejects immediately and discards all other pending or resolved results.

2.1 Promise.allSettled — Resilient Parallel Execution

Promise.allSettled waits for all promises to settle — fulfilled or rejected — and returns an array of outcome descriptors. No promise's result is ever discarded.

TYPESCRIPT
type SettledResult<T> =
  | { status: 'fulfilled'; value: T }
  | { status: 'rejected';  reason: unknown }

async function fetchDashboard(userId: string) {
  // ❌ Promise.all — one failure kills the entire dashboard
  const [user, orders, inventory] = await Promise.all([
    fetchUser(userId),
    fetchOrders(userId),
    fetchInventory(),
  ])

  // ✅ Promise.allSettled — all three run; partial data is still usable
  const results = await Promise.allSettled([
    fetchUser(userId),
    fetchOrders(userId),
    fetchInventory(),
  ])

  const user      = results[0].status === 'fulfilled' ? results[0].value : null
  const orders    = results[1].status === 'fulfilled' ? results[1].value : []
  const inventory = results[2].status === 'fulfilled' ? results[2].value : {}

  // Render what we have — degrade gracefully for missing sections
  return renderDashboard({ user, orders, inventory })
}

When to use: Any time you have multiple independent data sources and partial success is better than total failure. Dashboard aggregation, multi-source analytics, batch operations where per-item errors should not abort the batch.

2.2 Promise.any — First Success Wins

Promise.any resolves with the first fulfilled promise. It only rejects if all input promises reject — in which case it throws an AggregateError containing all the reasons.

TYPESCRIPT
async function fetchWithFallback(primaryUrl: string, fallbackUrl: string): Promise<Response> {
  // ❌ Promise.race — if primary rejects first (e.g., DNS failure),
  //    race settles immediately with the rejection, ignoring fallback
  return Promise.race([fetch(primaryUrl), fetch(fallbackUrl)])

  // ✅ Promise.any — if primary rejects, fallback still wins
  //    Returns the FIRST FULFILLED response, ignoring rejections
  return Promise.any([fetch(primaryUrl), fetch(fallbackUrl)])
  // Primary DNS failure → ignored, fallback response wins
  // Both fail → AggregateError containing both reasons
}

// CDN fallback pattern
async function loadAsset(path: string): Promise<ArrayBuffer> {
  const cdnHosts = [
    `https://cdn1.example.com${path}`,
    `https://cdn2.example.com${path}`,
    `https://cdn3.example.com${path}`,
  ]

  try {
    const response = await Promise.any(cdnHosts.map(url => fetch(url)))
    return response.arrayBuffer()
  } catch (err) {
    if (err instanceof AggregateError) {
      throw new Error(`All CDN hosts failed: ${err.errors.map(e => e.message).join(', ')}`)
    }
    throw err
  }
}

When to use: Fallback chains, redundant data sources (read from any healthy replica), geographic CDN selection.

2.3 Promise.race — First Settled Wins (Including Rejections)

Promise.race settles with the first promise to settle — fulfilled or rejected. This distinction from Promise.any is critical for timeout patterns:

TYPESCRIPT
// ✅ Timeout wrapper using Promise.race
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  const timeout = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error(`Operation timed out after ${ms}ms`)), ms)
  )
  return Promise.race([promise, timeout])
  // If the operation takes longer than ms, timeout rejects first
  // Promise.race settles with the rejection immediately
}

async function fetchWithTimeout(url: string): Promise<Response> {
  return withTimeout(fetch(url), 5000)
  // Rejects after 5s if fetch hasn't resolved
}

// ⚠️ Difference from Promise.any:
// Promise.race — race-condition detection (first to settle, rejection wins)
// Promise.any  — fallback selection (first to succeed, rejections ignored)

When to use: Timeout enforcement, detecting which of two operations is faster, speculative execution (run fast and slow paths, cancel the slower one).

2.4 The Decision Matrix

Comparison matrix diagram — 4 rows, 4 columns. Title in white: 'Promise Combinator Selection Guide'. Column headers in amber: 'Combinator', 'Resolves When', 'Rejects When', 'Primary Use Case'. Row 1 — 'Promise.all' (left-side red badge): 'ALL promises fulfilled' | 'FIRST rejection (fast-fail)' | 'Strictly dependent parallel work'. Row 2 — 'Promise.allSettled' (left-side green badge, cyan border — highlighted as Recommended): 'ALL promises settled (any outcome)' | 'Never (always resolves)' | 'Dashboard aggregation, batch operations'. Row 3 — 'Promise.any' (left-side violet badge): 'FIRST fulfillment' | 'ALL reject → AggregateError' | 'CDN fallback, redundant sources'. Row 4 — 'Promise.race' (left-side amber badge): 'FIRST settlement (fulfilled OR rejected)' | 'FIRST rejection' | 'Timeout enforcement, speculative execution'. Footer note in dim: 'Promise.allSettled is the correct default for independent parallel operations — reserve Promise.all for strictly sequential-dependent chains'.
Comparison matrix diagram — 4 rows, 4 columns. Title in white: 'Promise Combinator Selection Guide'. Column headers in amber: 'Combinator', 'Resolves When',…

3. Async Generators and Iterators

async/await is a point-in-time abstraction: you await a single resolved value. But many real-world data sources are not single values — they are streams of values that arrive over time. Async generators are the language primitive for expressing this.

3.1 The Core Mechanic — Pull-Based Iteration

TYPESCRIPT
// A regular async function produces ONE value
async function fetchPage(page: number): Promise<User[]> {
  const res = await fetch(`/api/users?page=${page}`)
  return res.json()
}

// An async generator produces MANY values, one at a time, on demand
async function* fetchAllPages(pageSize = 50): AsyncGenerator<User[], void, void> {
  let page = 0

  while (true) {
    const users = await fetch(`/api/users?page=${page}&size=${pageSize}`)
      .then(r => r.json() as Promise<User[]>)

    if (users.length === 0) return  // No more pages — generator terminates

    yield users  // ← Suspend here; resume when consumer calls .next()
    page++
  }
}

The yield keyword is the key: each yield suspends the generator and passes a value to the consumer. The generator does not advance to the next yield until the consumer explicitly asks for the next value. This is pull-based — the consumer controls the pace.

3.2 for await...of — Non-Blocking Large Dataset Iteration

TYPESCRIPT
// ❌ Naive approach: load all pages into memory first
async function exportAllUsersWrong(): Promise<void> {
  const allPages = await Promise.all(
    Array.from({ length: 100 }, (_, i) => fetchPage(i))
    // ← Fires ALL 100 requests simultaneously, holds ALL data in memory
    //   For 100 pages × 50 users × 2KB = 10 MB in memory before any export begins
  )
  await writeToCSV(allPages.flat())
}

// ✅ Streaming with async generator: O(1) memory — one page at a time
async function exportAllUsers(): Promise<void> {
  const csv = createWriteStream('users.csv')

  for await (const page of fetchAllPages()) {
    // Generator fetches page 0, yields — we process it, write to CSV, then
    // the loop calls .next() → generator fetches page 1, yields — repeat
    // At any point in time, only ONE page's data is in memory
    await writePageToCSV(csv, page)
  }

  csv.end()
}
Mental Model Check

An async generator is like a tap: it produces water (data) only when you open it (call .next()). Promise.all(pages) is filling a bathtub — it requests everything, holds it all, then lets you use it. for await...of on an async generator is drinking from the tap — you get one sip, process it, then ask for the next.

3.3 Processing Paginated APIs — Real Pattern

TYPESCRIPT
// Generic paginated API consumer — works for any API with cursor or page-based pagination
async function* paginatedFetch<T>(
  buildUrl: (cursor: string | null) => string,
  extractNextCursor: (response: PaginatedResponse<T>) => string | null
): AsyncGenerator<T, void, void> {
  let cursor: string | null = null

  while (true) {
    const response: PaginatedResponse<T> = await fetch(buildUrl(cursor))
      .then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}: ${buildUrl(cursor)}`)
        return r.json()
      })

    yield* response.items  // ← yield* delegates — yields each item individually

    cursor = extractNextCursor(response)
    if (cursor === null) return  // No next page — done
  }
}

// Usage: GitHub repository stars
for await (const star of paginatedFetch(
  cursor => `https://api.github.com/repos/owner/repo/stargazers?after=${cursor ?? ''}`,
  response => response.cursor ?? null
)) {
  await db.upsert('stars', star)
  // Each star processed and stored before the next page is fetched
}
Flow trace diagram in two panels. Top panel labeled 'Promise.all — Blocking (All Pages at Once)' with red left border. Timeline shows: T=0ms all 5 page requests fire simultaneously (5 cyan arrows pointing right, labeled page-0 through page-4). T=850ms (slowest page returns) — ALL data available simultaneously in memory. Below a memory indicator bar showing '5 pages × data = high watermark'. Bottom panel labeled 'Async Generator — Streaming (One Page at a Time)' with green left border. Timeline shows sequential steps: T=0ms page-0 request fires (single arrow). T=180ms page-0 yields, processed immediately (process icon). T=180ms page-1 request fires. T=360ms page-1 yields, processed. And so on through page-4. Memory indicator bar shows 'O(1) — only current page in memory'. Annotation between panels: 'Generator consumer controls the pace — next page fetched only when consumer requests it'.
Flow trace diagram in two panels. Top panel labeled 'Promise.all — Blocking (All Pages at Once)' with red left border. Timeline shows: T=0ms all 5 page reque…

4. Combining Generators with Combinators

Async generators and Promise combinators solve different dimensions of the async problem:

Problem Tool Why
Multiple independent parallel requests Promise.allSettled Resilient multi-source aggregation
Fallback between data sources Promise.any First success wins
Streaming sequential data Async generator + for await Lazy, memory-constant iteration
Streaming from multiple sources concurrently Async generator + Promise.race inside First available chunk from any source
TYPESCRIPT
// Streaming from multiple sources concurrently — generator composition
async function* mergeStreams<T>(...streams: AsyncIterable<T>[]): AsyncGenerator<T> {
  // Each stream produces values; we yield whichever arrives first
  const iterators = streams.map(s => s[Symbol.asyncIterator]())

  const pending = iterators.map((iter, i) =>
    iter.next().then(result => ({ result, index: i }))
  )

  while (pending.some(p => p !== null)) {
    const { result, index } = await Promise.race(pending.filter(Boolean))

    if (!result.done) {
      yield result.value
      pending[index] = iterators[index].next()
        .then(r => ({ result: r, index }))
    } else {
      pending[index] = null
    }
  }
}

Summary

Concept Rule
Promise.all All-or-nothing — use only when ALL results are required and one failure should abort
Promise.allSettled Resilient parallel — correct default for independent parallel operations
Promise.any First success — use for fallback chains and redundant sources
Promise.race First settled — use for timeout enforcement and speculative execution
Async generator Pull-based lazy sequence — produces one value per consumer .next() call
for await...of Non-blocking iteration — processes each yielded value before requesting the next
Memory advantage Async generator: O(1) per page · Promise.all(pages): O(n) for n pages

What's Next

In Part 4, we move from runtime scheduling to code structure: Factory, Singleton, Observer, and Proxy patterns — not as academic exercises, but as engine-level primitives that explain how Vue 3 reactivity, Node.js EventEmitter, and module singleton caches actually work. Understanding circular ESM dependency resolution turns a frustrating build error into a mechanical, solvable problem. Part 4 → Architectural Design Patterns


References

  1. MDN — Promise.allSettled()
  2. MDN — Promise.any()
  3. MDN — Promise.race()
  4. MDN — Async Generators and Async Iteration
  5. TC39 — Async Iteration Proposal (Stage 4)
  6. MDN — queueMicrotask()
Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#JavaScript#Async#Promise Combinators#Async Generators#Concurrency#Event Loop#Node.js
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.