Skip to content

Concurrent Async Patterns

advanced17 min read

The API That Was 4x Slower Than It Needed to Be

A team had this code powering their user profile page:

async function loadProfile(userId) {
  const user = await fetchUser(userId);
  const posts = await fetchPosts(userId);
  const followers = await fetchFollowers(userId);
  const recommendations = await fetchRecommendations(userId);
  return { user, posts, followers, recommendations };
}

Each API call takes ~200ms. Total: 800ms. But here's the thing — none of these calls depend on each other. They could all run at the same time. One Promise.all later:

async function loadProfile(userId) {
  const [user, posts, followers, recommendations] = await Promise.all([
    fetchUser(userId),
    fetchPosts(userId),
    fetchFollowers(userId),
    fetchRecommendations(userId),
  ]);
  return { user, posts, followers, recommendations };
}

Total: ~200ms. Same result, 4x faster. The difference between sequential and parallel async is often the difference between a snappy app and a sluggish one.

When to Parallelize (and When Not To)

Mental Model

Think of async operations like errands. If you need to pick up groceries, get gas, and grab coffee, and none depends on the other, you'd send three people to do them simultaneously. But if you need the groceries before you can cook, and you need to cook before you can eat — that's a dependency chain, and parallelizing doesn't help. The art of concurrent async is identifying which tasks are truly independent.

Parallelize when:

  • Tasks don't depend on each other's results
  • Tasks don't share mutable state
  • The server/API can handle concurrent requests

Stay sequential when:

  • Task B needs Task A's result as input
  • You need to respect rate limits
  • Order of side effects matters (database writes, for example)
Quiz
Given these three operations: (1) fetch user, (2) fetch user's team using user.teamId, (3) fetch global settings — which can run in parallel?

The Four Combinators in Depth

Promise.all: All Must Succeed

const [users, products, config] = await Promise.all([
  fetchUsers(),
  fetchProducts(),
  fetchConfig(),
]);

Use when: Every result is required. A page that needs all its data before rendering.

The short-circuit behavior is the critical detail. Promise.all rejects the moment ANY promise rejects — and only the FIRST rejection reason is exposed. If multiple promises reject, the other rejection reasons are silently swallowed, which can mask debugging issues (use Promise.allSettled if you need to see every failure). The remaining promises keep running (JavaScript has no built-in promise cancellation), but their results are discarded:

const controller = new AbortController();

try {
  const [a, b, c] = await Promise.all([
    fetch('/api/a', { signal: controller.signal }),
    fetch('/api/b', { signal: controller.signal }),
    fetch('/api/c', { signal: controller.signal }),
  ]);
} catch (err) {
  controller.abort();
}

By sharing an AbortController, you can actually cancel in-flight requests when one fails. Without this, failed Promise.all calls leave zombie requests consuming bandwidth.

Promise.allSettled: Get Every Result

const results = await Promise.allSettled([
  fetchUsers(),
  fetchProducts(),
  fetchConfig(),
]);

const succeeded = results
  .filter(r => r.status === 'fulfilled')
  .map(r => r.value);

const failed = results
  .filter(r => r.status === 'rejected')
  .map(r => r.reason);

Use when: You want partial results. A dashboard that shows whatever data is available. A batch operation where some items may fail.

Each result is either { status: 'fulfilled', value } or { status: 'rejected', reason }. Unlike Promise.all, it never short-circuits — every promise runs to completion.

Quiz
You're building a dashboard that shows 5 widgets, each loading data from a different API. If one API is down, you want to show the other 4 widgets normally and display an error state only for the failed widget. Which combinator should you use?

Promise.race: First to Settle Wins

const result = await Promise.race([
  fetch('/api/data'),
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error('Timeout')), 5000)
  ),
]);

Use when: You care about the first result, period — whether it's a fulfillment or a rejection.

The classic use case is timeouts. But there's a subtlety: Promise.race resolves/rejects with the first promise to settle, which includes rejections. If the fastest promise rejects, Promise.race rejects.

Common Trap

Promise.race with an empty iterable never settles. Promise.race([]) returns a promise that stays pending forever. This is per spec — there's no "first" element to determine the result. Always guard against empty arrays if the input is dynamic.

Promise.any: First Success Wins

const fastest = await Promise.any([
  fetch('https://cdn1.example.com/data.json'),
  fetch('https://cdn2.example.com/data.json'),
  fetch('https://cdn3.example.com/data.json'),
]);

Use when: You want the first successful result. Failed attempts are ignored unless all fail.

If ALL promises reject, Promise.any throws an AggregateError containing every rejection reason:

try {
  await Promise.any([
    Promise.reject(new Error('CDN 1 down')),
    Promise.reject(new Error('CDN 2 down')),
    Promise.reject(new Error('CDN 3 down')),
  ]);
} catch (err) {
  console.log(err instanceof AggregateError); // true
  console.log(err.errors.length);              // 3
}
Quiz
You're implementing a latency-optimized fetch that tries 3 CDN mirrors simultaneously. You want the data from whichever CDN responds first, but only if it succeeds. Which combinator fits?

Composing Combinators

Real-world scenarios often need multiple combinators working together. Here's a pattern for loading a page with critical and non-critical data:

async function loadPage(userId) {
  const [criticalData, optionalResults] = await Promise.all([
    Promise.all([
      fetchUser(userId),
      fetchPermissions(userId),
    ]),

    Promise.allSettled([
      fetchNotifications(userId),
      fetchRecommendations(userId),
      fetchActivityFeed(userId),
    ]),
  ]);

  const [user, permissions] = criticalData;
  const notifications = optionalResults[0].status === 'fulfilled'
    ? optionalResults[0].value
    : [];

  return { user, permissions, notifications };
}

The outer Promise.all ensures we fail fast if critical data is unavailable. The inner Promise.allSettled lets non-critical data fail gracefully. Both groups run in parallel.

The Dependency Graph Pattern

When you have a mix of dependent and independent operations, model them as a dependency graph:

async function buildOrder(userId, cartId) {
  const [user, cart] = await Promise.all([
    fetchUser(userId),
    fetchCart(cartId),
  ]);

  const [shippingRates, tax, inventory] = await Promise.all([
    calculateShipping(user.address, cart.items),
    calculateTax(user.address, cart.total),
    checkInventory(cart.items),
  ]);

  return { user, cart, shippingRates, tax, inventory };
}

Step 1: fetchUser and fetchCart are independent → parallel. Step 2: shipping, tax, and inventory all need user or cart data → parallel with each other, but sequential after step 1.

Two steps instead of five sequential calls. The key insight: maximize parallelism within each dependency level.

Quiz
What is the minimum number of sequential steps needed to execute: fetchA() (independent), fetchB() (independent), fetchC(needs A), fetchD(needs B), fetchE(needs C and D)?

Practical Pattern: Parallel With Shared Cancellation

When any operation in a parallel group fails, you often want to cancel the rest:

async function fetchAllOrNothing(urls) {
  const controller = new AbortController();

  try {
    return await Promise.all(
      urls.map(url =>
        fetch(url, { signal: controller.signal }).then(r => r.json())
      )
    );
  } catch (err) {
    controller.abort();
    throw err;
  }
}

Practical Pattern: Racing With a Timeout

The cleanest timeout pattern uses AbortSignal.timeout():

async function fetchWithTimeout(url, ms = 5000) {
  const response = await fetch(url, {
    signal: AbortSignal.timeout(ms),
  });
  return response.json();
}

But when you need to race arbitrary async operations (not just fetch), compose Promise.race with a timeout promise:

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

const data = await withTimeout(complexComputation(), 3000);
What developers doWhat they should do
Awaiting independent operations sequentially
Sequential awaits add up latency. Three 200ms calls take 600ms sequentially but 200ms in parallel
Wrap independent operations in Promise.all
Using Promise.all when partial failure is acceptable
Promise.all rejects on the first failure, discarding all successful results. allSettled gives you everything
Use Promise.allSettled for graceful degradation
Using Promise.race for CDN failover
Promise.race resolves with the first settlement, including rejections. If the fastest CDN is down, race gives you the error, not the next successful CDN
Use Promise.any — it ignores rejections
Passing an empty array to Promise.race
Promise.race([]) returns a forever-pending promise, which is almost certainly a bug
Guard against empty input or use a timeout as fallback
Interview Question

Design a function that fetches user data from a primary API and a fallback API. It should try the primary first, but if the primary doesn't respond within 2 seconds, it should also start the fallback. Return whichever responds successfully first. If both fail, throw an AggregateError.