π¦ 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, oranyβ 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.
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.
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 };
}
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.
| Combinator | Settles when⦠| Rejects when⦠| Use it for |
|---|---|---|---|
Promise.all | all fulfil | any one rejects (fail-fast) | All-or-nothing data loads |
Promise.allSettled | all settle | never β you inspect each result | Independent parts that may fail |
Promise.race | first settles (fulfil or reject) | if the first to settle rejects | Timeouts, fastest-of-N |
Promise.any | first fulfils | only 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 Workers | Poor fit |
|---|---|
| Image/video processing | DOM manipulation (workers can't touch the DOM) |
| Heavy math, simulations | Tiny, quick tasks (message overhead dominates) |
| Parsing large datasets | Anything 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:
- Start from
mapWithConcurrencyabove. - Modify the
taskwrapper so a single item's failure does not abort the whole batch. Store{ ok: true, value }or{ ok: false, error }per item. - Run it over a list of 20 fake URLs where a few deliberately reject, with a concurrency limit of 3.
- 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.allinstead of awaiting each in turn. - Use
allSettledwhen partial success is acceptable. - Cap concurrency for bulk work β pick a limit that respects the slowest resource in the chain.
- Reserve
racefor timeouts andanyfor failover. - Offload CPU-bound work to Web Workers to keep the UI responsive.
β οΈ Don't
- Sequentially
awaitoperations that have no dependency on each other. - Unleash unbounded
Promise.allover huge lists. - Assume
raceignores 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
- MDN β Promise (all/allSettled/race/any)
- MDN β The event loop
- MDN β Using Web Workers
- p-limit β a tiny concurrency limiter
π 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.