🚀AdvancedConcurrency & Streams

Advanced Async Patterns

Master async generators, async iteration, structured concurrency with AbortController, and advanced Promise combinators for robust asynchronous JavaScript.

Overview

Once you understand promises and async/await (the intermediate level), the next challenge is coordinating multiple async operations correctly. Real applications need patterns like: running tasks with a concurrency limit so you don't overwhelm an API; cancelling an in-flight fetch when the user navigates away; consuming paginated data lazily without loading everything upfront; and implementing retry logic with exponential backoff. These are the patterns that separate production-grade async code from tutorial-level code. Async generators (async function*) combine the pull-based laziness of generators with the await-based asynchrony of async functions. They are the natural fit for paginated APIs, database cursors, event queues, and any sequence that is both asynchronous and unbounded. The for-await-of loop consumes them ergonomically, and they integrate seamlessly with the WHATWG Streams API's async-iterable protocol. AbortController and AbortSignal are the standard mechanism for cancellation in modern JavaScript. Passing a signal to fetch, setTimeout via AbortSignal.timeout(), and any custom async operation gives you a composable, tree-structured cancellation mechanism. A single AbortController can cancel an entire tree of operations a fetch, its stream reader, and any derived work with a single abort() call. Understanding how to propagate and check abort signals correctly is essential for building responsive, leak-free applications.

Key Points

  • •Async generators (async function*) yield Promises for-await-of awaits each yielded value in turn
  • •AsyncIterable: any object with [Symbol.asyncIterator]() that returns an async iterator
  • •AbortController.abort() cancels all operations that received its signal pass the same signal to related operations
  • •AbortSignal.timeout(ms) creates a self-cancelling signal without a controller for simple timeout scenarios
  • •Promise.allSettled() collects all results (fulfilled and rejected) without short-circuiting on failure
  • •Promise.any() resolves as soon as the first promise fulfils ideal for racing multiple data sources
  • •Concurrency limiting: run at most N promises simultaneously with a semaphore or batching pattern
  • •Retry with exponential backoff: double the delay after each failure, add jitter to avoid thundering herd

Examples

Async generator for paginated API iteration

async function* fetchPages(baseUrl, pageSize = 20) {
  let page = 1;
  let hasMore = true;

  while (hasMore) {
    const response = await fetch(`${baseUrl}?page=${page}&limit=${pageSize}`);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);

    const data = await response.json();
    yield data.items; // yield each page as a batch

    hasMore = data.hasNextPage;
    page++;
  }
}

// Consume lazily only fetches the next page when needed
async function main() {
  let total = 0;
  for await (const page of fetchPages('https://api.example.com/items')) {
    console.log(`Got ${page.length} items`);
    total += page.length;
    // Could break early here without fetching remaining pages
    if (total >= 100) break;
  }
  console.log(`Total processed: ${total}`);
}

The async generator fetches the next page only when the loop body requests it lazy evaluation in async form.

AbortController cancellation and timeout

// Cancel fetch when component unmounts
function fetchWithCleanup(url) {
  const controller = new AbortController();

  const promise = fetch(url, { signal: controller.signal })
    .then(r => r.json())
    .catch(err => {
      if (err.name === 'AbortError') {
        console.log('Fetch was cancelled');
        return null;
      }
      throw err;
    });

  return { promise, cancel: () => controller.abort() };
}

const { promise, cancel } = fetchWithCleanup('https://api.example.com/data');
// Simulate component unmounting after 500ms
setTimeout(cancel, 500);
const data = await promise; // null if cancelled

// Built-in timeout via AbortSignal.timeout()
try {
  const response = await fetch('https://api.example.com/slow', {
    signal: AbortSignal.timeout(3000), // auto-cancels after 3s
  });
  const json = await response.json();
  console.log(json);
} catch (err) {
  if (err.name === 'TimeoutError') console.error('Request timed out');
  else throw err;
}

AbortSignal.timeout() is the modern alternative to wrapping fetch in a Promise.race with a timeout.

Concurrency limiting and retry with exponential backoff

// Run at most 'limit' async tasks simultaneously
async function withConcurrencyLimit(tasks, limit) {
  const results = [];
  const executing = new Set();

  for (const task of tasks) {
    const p = task().then(result => {
      executing.delete(p);
      return result;
    });
    executing.add(p);
    results.push(p);

    if (executing.size >= limit) {
      await Promise.race(executing); // wait for one slot to free up
    }
  }

  return Promise.all(results);
}

// Retry with exponential backoff + jitter
async function retry(fn, { attempts = 3, baseDelay = 500, jitter = 0.2 } = {}) {
  for (let attempt = 0; attempt < attempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt === attempts - 1) throw err;
      const delay = baseDelay * 2 ** attempt;
      const jitterMs = delay * jitter * (Math.random() * 2 - 1);
      console.warn(`Attempt ${attempt + 1} failed, retrying in ${Math.round(delay + jitterMs)}ms`);
      await new Promise(r => setTimeout(r, delay + jitterMs));
    }
  }
}

// Example: fetch with retry
const data = await retry(
  () => fetch('https://flaky.api.com/data').then(r => r.json()),
  { attempts: 4, baseDelay: 250 }
);

Jitter prevents the "thundering herd" problem where all retrying clients hit the server at the same instant after a failure.

Common Mistakes

  • Not checking signal.aborted in long-running loops fetch respects AbortSignal, but your own async logic must check it manually between steps
  • Awaiting Promise.all with a large array of independent promises, overwhelming the downstream API always limit concurrency for I/O-bound work
  • Using for-await-of on a never-ending async generator without a break condition this hangs the program; always handle cancellation
  • Swallowing AbortError as a generic catch-all always re-throw non-abort errors so bugs are not silently hidden

Note

For complex async workflows in production, consider the AbortSignal composition pattern: create a parent controller for the full operation, and pass parent.signal to every sub-operation. A single parent.abort() cascades cancellation through all layers fetch, stream reader, custom loops, timers simultaneously, with no manual teardown list to maintain.

Exercises

Write JavaScript code to solve each exercise. Run it and check your output.

Loading editor…

Completed: 0 / 5 exercises

Knowledge Check

Question 1 of 4

What is the key difference between an async generator and a regular async function?