Structured Concurrency in JavaScript: Beyond Promise.all

Published: (April 7, 2026 at 08:31 AM EDT)
4 min read
Source: Dev.to

Source: Dev.to

The Problem with Unstructured Async Code

JavaScript async code has a scope problem. You fire off promises and hope they complete—or fail—cleanly. When something goes wrong mid‑flight, cleanup is your responsibility.

Partial‑failure example

async function loadDashboard(userId: string) {
  const [user, orders, analytics] = await Promise.all([
    getUser(userId),
    getOrders(userId),     // This throws after 2 seconds
    getAnalytics(userId), // This is still running!
  ]);
  // getAnalytics never gets cancelled
}

When getOrders rejects, Promise.all rejects—but getAnalytics keeps running in the background, consuming resources and potentially writing stale data.

Handling each result individually

async function loadDashboard(userId: string) {
  const results = await Promise.allSettled([
    getUser(userId),
    getOrders(userId),
    getAnalytics(userId),
  ]);

  const [userResult, ordersResult, analyticsResult] = results;

  const user = userResult.status === 'fulfilled' ? userResult.value : null;
  const orders = ordersResult.status === 'fulfilled' ? ordersResult.value : [];

  if (analyticsResult.status === 'rejected') {
    console.error('Analytics failed:', analyticsResult.reason);
  }

  return {
    user,
    orders,
    analytics:
      analyticsResult.status === 'fulfilled' ? analyticsResult.value : null,
  };
}

Timeouts and Cancellation

Abortable fetch with timeout

async function fetchWithTimeout(url: string, timeoutMs: number): Promise {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal: controller.signal });
    return response.json();
  } finally {
    clearTimeout(timeoutId);
  }
}

React cleanup example

function useUserData(userId: string) {
  const [data, setData] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(r => r.json())
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') throw err;
        // AbortError is expected on cleanup, ignore it
      });

    return () => controller.abort(); // cleanup
  }, [userId]);

  return data;
}

Generic timeout wrapper

function withTimeout(promise: Promise, ms: number): Promise {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
  );
  return Promise.race([promise, timeout]);
}

Concurrency Patterns

Fastest cache or network

async function getWithFallback(key: string) {
  return Promise.race([
    redis.get(key).then(v => JSON.parse(v!)), // cache
    db.slowQuery(key),                         // database
  ]);
}

First‑successful CDN mirror

async function fetchFromCDN(path: string) {
  return Promise.any([
    fetch(`https://cdn1.example.com${path}`),
    fetch(`https://cdn2.example.com${path}`),
    fetch(`https://cdn3.example.com${path}`),
  ]);
  // Rejects only if ALL fail (AggregateError)
}

Batch Processing with Controlled Parallelism

Running 1 000 tasks in parallel can overwhelm databases and APIs. Process them in batches:

async function processInBatches(
  items: T[],
  processor: (item: T) => Promise,
  concurrency: number
): Promise {
  const results: R[] = [];

  for (let i = 0; i  limit(() => processUser(user)))
);

// All 1 000 tasks are queued, but only 10 run at once

Streaming with Async Generators

When you need to handle large data sets without loading everything into memory:

async function* generateUsers(): AsyncGenerator {
  let page = 1;
  while (true) {
    const users = await db.users.findMany({ skip: (page - 1) * 100, take: 100 });
    if (users.length === 0) return;
    yield* users;
    page++;
  }
}

// Process without loading all into memory
for await (const user of generateUsers()) {
  await sendEmail(user.email);
}

Choosing the Right Concurrency Primitive

PrimitiveWhen to Use
Promise.allAll tasks must succeed; fail fast on first error
Promise.allSettledNeed results of every task regardless of outcome
Promise.raceFirst task to finish (success or failure) decides
Promise.anyFirst successful task; rejects only if all fail
AbortControllerCancel in‑flight requests (e.g., fetch)
p-limit / semaphoreControlled parallelism to avoid overload
Async generatorsStream large sequences without full materialization

These utilities, combined with proper error handling, give you structured concurrency in JavaScript—moving beyond the pitfalls of raw Promise.all.

0 views
Back to Blog

Related posts

Read more »