Structured Concurrency in JavaScript: Beyond Promise.all
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
| Primitive | When to Use |
|---|---|
Promise.all | All tasks must succeed; fail fast on first error |
Promise.allSettled | Need results of every task regardless of outcome |
Promise.race | First task to finish (success or failure) decides |
Promise.any | First successful task; rejects only if all fail |
AbortController | Cancel in‑flight requests (e.g., fetch) |
p-limit / semaphore | Controlled parallelism to avoid overload |
| Async generators | Stream 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.