Skip to main content

🚦 Concurrent Operations Management

A modern page rarely does one async thing at a time β€” it loads a profile, its posts, and its notifications all at once. Do that badly and you either crawl (waiting for each in turn) or stampede a server with hundreds of simultaneous requests. This lesson teaches you to orchestrate many operations: when to run them in parallel, how to combine their results, and how to keep concurrency under control.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain JavaScript's single-threaded, event-loop concurrency model and what it can and cannot do
  • Choose between sequential and parallel execution based on data dependencies
  • Pick the right Promise combinator β€” all, allSettled, race, or any β€” for the job
  • Implement a concurrency limiter to cap how many operations run at once
  • Recognise when to reach for Web Workers for genuine parallelism

Estimated Time: 45–60 minutes  β€’  Difficulty: Intermediate

Hands-on: Write a mapWithConcurrency() helper that processes a big list N-at-a-time.

In This Lesson

The Kitchen Analogy

Picture a restaurant kitchen at dinner rush. A cook who prepares one dish start-to-finish before touching the next would fall hopelessly behind. Instead, dishes cook concurrently: the pasta boils while the sauce reduces while the bread toasts. Some steps depend on others (you can't plate before the pasta is done), and the number of burners is finite (you can't cook fifty dishes at once).

Managing concurrent async operations is exactly this: some work can overlap, some must wait for prerequisites, and you must respect limited resources β€” a server's rate limit, the browser's connection pool, available memory.

graph LR A[Concurrent work] --> B[Sequential
one after another] A --> C[Parallel
all at once] A --> D[Race
first to settle] A --> E[Limited
N at a time]

JavaScript's Concurrency Model

JavaScript runs your code on a single thread. It never executes two lines of your JS at the same instant. So how does it juggle dozens of in-flight requests? Through the event loop: slow operations (network, timers, file I/O) are handed off to the host environment, and their callbacks are queued to run later, when the call stack is empty.

flowchart TD A[Call Stack runs your code] --> B{Stack empty?} B -->|No| A B -->|Yes| C[Microtask queue
then Task queue] C --> D{Callback waiting?} D -->|Yes| E[Push callback to stack] E --> A D -->|No| B

Two consequences follow, and both matter for this lesson:

  • "Concurrent" is not "parallel." While a fetch is in flight, other JS can run β€” that is concurrency. But two pieces of your JavaScript never run literally at the same time (unless you use Web Workers, which have their own thread).
  • Long synchronous work blocks everything. A CPU-heavy loop freezes the UI, timers, and pending callbacks, because nothing else can run until it finishes. That is precisely the case Web Workers exist to solve.

πŸ“– Microtasks vs tasks

Resolved promises schedule microtasks, which run before the next task (like a setTimeout callback). That is why Promise.resolve().then(...) fires before a setTimeout(..., 0) queued just before it.

Sequential vs Parallel

The single most common async performance bug is awaiting independent operations one at a time. Compare:

// ❌ Sequential β€” each await blocks the next, even though they're independent.
async function loadSlow(userId) {
  const posts = await fetchPosts(userId);        // 300ms
  const followers = await fetchFollowers(userId); // 300ms
  const notes = await fetchNotifications(userId); // 300ms
  return { posts, followers, notes };            // ~900ms total
}

// βœ… Parallel β€” start all three, then await together.
async function loadFast(userId) {
  const [posts, followers, notes] = await Promise.all([
    fetchPosts(userId),
    fetchFollowers(userId),
    fetchNotifications(userId),
  ]);
  return { posts, followers, notes };            // ~300ms total
}

The rule is about dependencies. Run sequentially only when a later step needs an earlier step's result; otherwise, run in parallel. Most real code is a hybrid: fetch the prerequisite first, then fan out.

async function loadProfile(userId) {
  // Sequential prerequisite: we need the user before we can fan out.
  const user = await fetchUser(userId);

  // Now everything independent runs in parallel.
  const [posts, followers, following] = await Promise.all([
    fetchPosts(user.id),
    fetchFollowers(user.id),
    fetchFollowing(user.id),
  ]);

  return { user, posts, followers, following };
}
Sequential vs parallel timelines Three tasks run back-to-back sequentially taking the sum of their durations, versus overlapping in parallel taking only as long as the longest task. Sequential β€” sum of durations Task A Task B Task C Parallel β€” longest task only Task A Task B (longest) Task C done
Figure 1 β€” Independent tasks run sequentially take the sum of their times; run in parallel they take only as long as the slowest one.

The Four Promise Combinators

JavaScript ships four built-in ways to combine an array of promises. Choosing the right one is mostly about how you want to handle partial failure.

CombinatorSettles when…Rejects when…Use it for
Promise.allall fulfilany one rejects (fail-fast)All-or-nothing data loads
Promise.allSettledall settlenever β€” you inspect each resultIndependent parts that may fail
Promise.racefirst settles (fulfil or reject)if the first to settle rejectsTimeouts, fastest-of-N
Promise.anyfirst fulfilsonly if all reject (AggregateError)Failover across mirrors

Promise.all β€” all or nothing

Runs everything in parallel and resolves to an array of results in order. If any promise rejects, all rejects immediately with that reason.

try {
  const [account, transactions, prices] = await Promise.all([
    fetchAccount(userId),
    fetchTransactions(userId),
    fetchPrices(),
  ]);
  renderDashboard({ account, transactions, prices });
} catch (error) {
  // One failure sinks the whole load.
  showError('The dashboard could not load. Please try again.');
}

Promise.allSettled β€” collect every outcome

When some pieces are non-critical, allSettled never rejects. It gives you an array of { status, value } or { status, reason } objects so you can render what succeeded and flag what didn't.

const results = await Promise.allSettled([
  fetchAccount(userId),
  fetchTransactions(userId),
  fetchPrices(),
]);

const widgets = results.map((r) =>
  r.status === 'fulfilled'
    ? { status: 'ok', data: r.value }
    : { status: 'error', message: r.reason.message }
);
// Render each widget independently; failures don't sink the page.

Promise.race β€” first to settle wins

Resolves or rejects as soon as the first promise settles, whichever it is. The classic use is bounding an operation with a timeout.

function timeout(ms) {
  return new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms));
}

// Whichever settles first wins β€” the data or the timeout.
const data = await Promise.race([fetch('/api/slow').then(r => r.json()), timeout(5000)]);

Promise.any β€” first to succeed wins

Ignores rejections and resolves with the first fulfilled value. Perfect for querying several mirrors and taking whichever answers first. If they all fail, it rejects with an AggregateError whose .errors lists each reason.

const mirrors = [
  'https://api-primary.example.com',
  'https://api-backup1.example.com',
  'https://api-backup2.example.com',
];

try {
  const data = await Promise.any(
    mirrors.map(base => fetch(`${base}/resource`).then(r => {
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      return r.json();
    }))
  );
  console.log('First successful mirror:', data);
} catch (error) {
  console.error('All mirrors failed:', error.errors);
}

⚠️ race vs any β€” don't mix them up

race settles on the first result of any kind β€” so a fast rejection wins and rejects the whole thing. any waits for the first success and only rejects if everything fails. Use race for timeouts, any for failover.

Limiting Concurrency

Promise.all(urls.map(fetch)) is fine for five URLs. For five thousand, you'll open thousands of sockets at once, blow past API rate limits, and possibly crash the tab. The fix is a concurrency limiter: process the list but keep only N operations in flight at any moment.

Here is a compact, dependency-free implementation. A pool of "workers" each pulls the next index from a shared cursor until the list is exhausted.

async function mapWithConcurrency(items, limit, task) {
  const results = new Array(items.length);
  let nextIndex = 0;

  async function worker() {
    while (nextIndex < items.length) {
      const index = nextIndex++;          // claim an item
      results[index] = await task(items[index], index);
    }
  }

  // Start `limit` workers; they drain the queue in parallel.
  const size = Math.min(limit, items.length);
  await Promise.all(Array.from({ length: size }, worker));
  return results;
}

// Process 5000 image URLs, but only 4 downloads at a time.
const processed = await mapWithConcurrency(imageUrls, 4, async (url) => {
  const image = await fetchImage(url);
  return processImage(image);
});

Because each worker awaits its current task before claiming the next index, exactly limit tasks are ever running. Raise the limit for more throughput; lower it to be gentle on a fragile API.

πŸ’‘ When to limit concurrency

  • APIs with published rate limits or quotas
  • Bulk jobs over large datasets (thousands of items)
  • Memory-heavy work where holding everything at once would exhaust RAM
  • Being a polite client to a shared or third-party service

For production, battle-tested libraries like p-limit and p-map package this pattern with extra options.

Related: rate limiting and backoff

Concurrency limiting caps how many run at once; rate limiting caps how often you start them (e.g. 10 per second). And when a server responds 429 Too Many Requests, honour its Retry-After header and back off β€” the exponential-backoff pattern from the previous lesson applies directly here.

Web Workers for True Parallelism

Everything so far is concurrency on one thread β€” great for I/O-bound work that spends its time waiting. But a genuinely CPU-bound task (image filtering, parsing a huge file, cryptography) will freeze the UI no matter how you schedule it, because it hogs the single thread. Web Workers run JavaScript on a separate OS thread, giving you real parallelism.

// worker.js β€” runs on its own thread, no DOM access.
self.addEventListener('message', (event) => {
  const { id, numbers } = event.data;
  const sum = numbers.reduce((total, n) => total + Math.sqrt(n), 0);
  self.postMessage({ id, sum });
});

// main.js β€” offload the heavy loop so the UI stays responsive.
const worker = new Worker('worker.js');

function computeInWorker(numbers) {
  return new Promise((resolve) => {
    const id = crypto.randomUUID();
    const onMessage = (event) => {
      if (event.data.id !== id) return;
      worker.removeEventListener('message', onMessage);
      resolve(event.data.sum);
    };
    worker.addEventListener('message', onMessage);
    worker.postMessage({ id, numbers });
  });
}

const sum = await computeInWorker(bigArrayOfNumbers);
Good for Web WorkersPoor fit
Image/video processingDOM manipulation (workers can't touch the DOM)
Heavy math, simulationsTiny, quick tasks (message overhead dominates)
Parsing large datasetsAnything I/O-bound β€” await already handles that

⚠️ Workers aren't free

Data is copied to and from a worker (structured clone), so passing huge objects can cost more than the computation saved. For large binary buffers, use transferable objects to hand over ownership without a copy.

Hands-on Exercise

πŸ‹οΈ A resilient batch processor

Objective: Combine concurrency limiting with per-item failure isolation.

Instructions:

  1. Start from mapWithConcurrency above.
  2. Modify the task wrapper so a single item's failure does not abort the whole batch. Store { ok: true, value } or { ok: false, error } per item.
  3. Run it over a list of 20 fake URLs where a few deliberately reject, with a concurrency limit of 3.
  4. At the end, log how many succeeded and how many failed.
πŸ’‘ Hint

Wrap the call to task in its own try/catch inside the worker so a rejection becomes a stored result rather than a thrown error that stops the worker. This is essentially allSettled semantics, but with a concurrency cap.

βœ… Sample solution
async function batchProcess(items, limit, task) {
  const results = new Array(items.length);
  let next = 0;

  async function worker() {
    while (next < items.length) {
      const i = next++;
      try {
        results[i] = { ok: true, value: await task(items[i], i) };
      } catch (error) {
        results[i] = { ok: false, error };
      }
    }
  }

  await Promise.all(
    Array.from({ length: Math.min(limit, items.length) }, worker)
  );
  return results;
}

// Demo
const urls = Array.from({ length: 20 }, (_, i) => `/api/item/${i}`);
const results = await batchProcess(urls, 3, async (url, i) => {
  if (i % 7 === 0) throw new Error(`Bad item ${i}`); // some fail on purpose
  await new Promise(r => setTimeout(r, 100));
  return `data for ${url}`;
});

const ok = results.filter(r => r.ok).length;
console.log(`${ok} succeeded, ${results.length - ok} failed`);

🎯 Quick Quiz

Question 1: You're loading five dashboard widgets and want to render each that succeeds while showing an error on any that fail. Which combinator fits?

Question 2: Why does Promise.all(urls.map(fetch)) become dangerous when urls has thousands of entries?

Question 3: A CPU-heavy calculation freezes your page's UI. What is the appropriate tool?

Best Practices

βœ… Do

  • Fire independent operations in parallel with Promise.all instead of awaiting each in turn.
  • Use allSettled when partial success is acceptable.
  • Cap concurrency for bulk work β€” pick a limit that respects the slowest resource in the chain.
  • Reserve race for timeouts and any for failover.
  • Offload CPU-bound work to Web Workers to keep the UI responsive.

⚠️ Don't

  • Sequentially await operations that have no dependency on each other.
  • Unleash unbounded Promise.all over huge lists.
  • Assume race ignores rejections β€” it doesn't.
  • Push megabytes into a Web Worker by copy when a transferable would avoid it.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • JavaScript is single-threaded; the event loop delivers concurrency, and Web Workers add real parallelism.
  • Run in parallel unless a data dependency forces sequential order; most real code is a hybrid.
  • Four combinators β€” all (all-or-nothing), allSettled (every outcome), race (first to settle), any (first success).
  • A concurrency limiter keeps exactly N operations in flight β€” essential for bulk work.
  • Web Workers move CPU-bound work off the main thread so the UI stays smooth.

πŸ“š Further Reading

πŸš€ What's Next?

You can now run many operations at once and combine their results. But some data doesn't arrive as a fixed batch β€” it streams in over time, page by page or message by message. The next lesson, Async Generators and Iteration, shows how to consume those streams lazily with for await...of.

πŸŽ‰ Great work!

You've gone from one operation at a time to orchestrating dozens without overwhelming anything. Next, streams over time.