Concurrency in JavaScript Is Not What You Think
Concurrency in JavaScript Is Not What You Think
Promise.all vs Promise.allSettled vs Promise.race, structured concurrency patterns, AbortController for cancellation, and designing async workflows that don't silently fail in edge cases.
The Misconception
JavaScript is single-threaded. There's no parallelism. So concurrency must be simple, right?
Wrong. JavaScript's concurrency model — cooperative, event-loop-based, with Promises as the primitive — creates a unique set of failure modes that don't exist in thread-based languages. And most codebases get them wrong.
// Looks innocent
const results = await Promise.all([
fetchUser(userId),
fetchOrders(userId),
fetchRecommendations(userId),
]);
// But what happens when:
// - fetchOrders throws after 2 seconds
// - fetchUser is still pending
// - fetchRecommendations already succeeded and wrote to a database
// Answer: fetchUser keeps running. You might have orphaned operations.
// The rejected promise surfaces, but the side effects already happened.
This post is about the failure modes nobody talks about, and the patterns that handle them correctly.
The Promise Combinators: What They Actually Do
Promise.all: Fail-Fast, No Cleanup
┌─────────────────────────────────────────────────────────────────────────────┐
│ PROMISE.ALL BEHAVIOR │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Promise.all([p1, p2, p3]) │
│ │
│ Timeline: │
│ ───────────────────────────────────────────────────────────────────────── │
│ t=0ms All promises start executing │
│ t=100ms p1 resolves ✓ │
│ t=200ms p3 resolves ✓ │
│ t=500ms p2 REJECTS ✗ │
│ │
│ Result: │
│ • Promise.all immediately rejects with p2's error │
│ • p1 and p3's results are DISCARDED (you never see them) │
│ • Any promises still pending CONTINUE RUNNING (not cancelled) │
│ • Side effects from resolved promises already happened │
│ │
│ If p3 was: await db.insert(record) — that record exists now │
│ You have no reference to it. No way to clean up. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
// The danger
async function createOrderWithItems(order: Order, items: Item[]): Promise<void> {
await Promise.all([
db.orders.insert(order),
...items.map(item => db.orderItems.insert(item)),
]);
}
// If third item insert fails:
// - Order is already in DB
// - First two items are already in DB
// - Database is now inconsistent
// - No error handler can fix this — you don't know what succeeded
When to use: All operations must succeed, none have side effects that need cleanup, or you have a transaction wrapper.
Promise.allSettled: Complete Picture, No Short-Circuit
┌─────────────────────────────────────────────────────────────────────────────┐
│ PROMISE.ALLSETTLED BEHAVIOR │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Promise.allSettled([p1, p2, p3]) │
│ │
│ Timeline: │
│ ───────────────────────────────────────────────────────────────────────── │
│ t=0ms All promises start executing │
│ t=100ms p1 resolves ✓ │
│ t=500ms p2 rejects ✗ │
│ t=800ms p3 resolves ✓ │
│ t=800ms Promise.allSettled resolves │
│ │
│ Result: │
│ [ │
│ { status: 'fulfilled', value: p1Result }, │
│ { status: 'rejected', reason: p2Error }, │
│ { status: 'fulfilled', value: p3Result }, │
│ ] │
│ │
│ • ALWAYS resolves (never rejects) │
│ • Waits for ALL promises to settle (no short-circuit) │
│ • You get full visibility into what succeeded and what failed │
│ • You can implement cleanup logic │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
// Better: full visibility and cleanup capability
async function createOrderWithItems(order: Order, items: Item[]): Promise<void> {
const orderId = await db.orders.insert(order);
const results = await Promise.allSettled(
items.map(item => db.orderItems.insert({ ...item, orderId }))
);
const failures = results.filter(
(r): r is PromiseRejectedResult => r.status === 'rejected'
);
if (failures.length > 0) {
// We know exactly what failed
// We can clean up the order
await db.orders.delete(orderId);
// Re-throw with context
throw new AggregateError(
failures.map(f => f.reason),
`Failed to create ${failures.length} of ${items.length} order items`
);
}
}
When to use: You need visibility into partial failures, or you need to implement cleanup/rollback logic.
Promise.race: First to Settle Wins
┌─────────────────────────────────────────────────────────────────────────────┐
│ PROMISE.RACE BEHAVIOR │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Promise.race([p1, p2, p3]) │
│ │
│ Timeline (scenario 1 — first resolves): │
│ ───────────────────────────────────────────────────────────────────────── │
│ t=0ms All promises start executing │
│ t=100ms p1 resolves ✓ │
│ Promise.race resolves with p1's value │
│ t=500ms p2 resolves (ignored) │
│ t=800ms p3 rejects (IGNORED — but still runs!) │
│ │
│ Timeline (scenario 2 — first rejects): │
│ ───────────────────────────────────────────────────────────────────────── │
│ t=0ms All promises start executing │
│ t=50ms p2 rejects ✗ │
│ Promise.race rejects with p2's error │
│ t=100ms p1 resolves (ignored) │
│ t=800ms p3 resolves (ignored) │
│ │
│ CRITICAL: Losing promises are NOT cancelled. They continue running. │
│ They can still cause side effects, consume resources, log errors. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
// Classic mistake: timeout implementation
async function fetchWithTimeout<T>(
promise: Promise<T>,
timeoutMs: number
): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
),
]);
}
// Problem: if timeout wins, the fetch CONTINUES
// - Network request still pending
// - Response will be received (and ignored)
// - If response is huge, it still downloads
// - Server still does the work
// - Connection pool slot still occupied
// This is resource leak, not cancellation
When to use: Racing truly independent operations where you don't care about the losers, or combined with proper cancellation (covered later).
Promise.any: First Success Wins
┌─────────────────────────────────────────────────────────────────────────────┐
│ PROMISE.ANY BEHAVIOR │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Promise.any([p1, p2, p3]) │
│ │
│ Timeline: │
│ ───────────────────────────────────────────────────────────────────────── │
│ t=0ms All promises start executing │
│ t=100ms p1 rejects ✗ (ignored, waiting for success) │
│ t=200ms p3 resolves ✓ │
│ Promise.any resolves with p3's value │
│ t=500ms p2 rejects (ignored) │
│ │
│ If ALL reject: │
│ Promise.any rejects with AggregateError containing all errors │
│ │
│ Use case: Redundant requests, fallback sources │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
// Good use case: try multiple CDNs
async function fetchFromCDN(assetPath: string): Promise<Response> {
return Promise.any([
fetch(`https://cdn1.example.com${assetPath}`),
fetch(`https://cdn2.example.com${assetPath}`),
fetch(`https://cdn3.example.com${assetPath}`),
]).catch((aggregateError: AggregateError) => {
// All CDNs failed
throw new Error(
`Asset unavailable from all CDNs: ${aggregateError.errors.map(e => e.message).join(', ')}`
);
});
}
// Warning: All three requests fire. You're tripling your outbound traffic.
// Use wisely — not for every request.
The Fundamental Problem: No Structured Concurrency
Languages like Go, Kotlin, and Swift have structured concurrency: when a parent scope exits, all child concurrent operations are cancelled. JavaScript has no such concept.
┌─────────────────────────────────────────────────────────────────────────────┐
│ STRUCTURED VS UNSTRUCTURED CONCURRENCY │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ GO (structured): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ func handler(ctx context.Context) { │ │
│ │ g, ctx := errgroup.WithContext(ctx) │ │
│ │ g.Go(func() error { return fetchA(ctx) }) │ │
│ │ g.Go(func() error { return fetchB(ctx) }) │ │
│ │ g.Go(func() error { return fetchC(ctx) }) │ │
│ │ err := g.Wait() │ │
│ │ // If ANY fails, ctx is cancelled, ALL goroutines stop │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ JAVASCRIPT (unstructured): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ async function handler() { │ │
│ │ await Promise.all([ │ │
│ │ fetchA(), │ │
│ │ fetchB(), │ │
│ │ fetchC(), │ │
│ │ ]); │ │
│ │ // If B fails, A and C keep running. No cancellation. │ │
│ │ // If handler's caller times out, ALL THREE keep running. │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ The JavaScript model leaks concurrent operations by default. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
AbortController: Manual Structured Concurrency
AbortController is JavaScript's tool for cancellation, but it requires explicit wiring.
Basic Pattern
async function fetchUserData(userId: string, signal?: AbortSignal): Promise<UserData> {
// Check if already aborted
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
// Pass signal to fetch
const response = await fetch(`/api/users/${userId}`, { signal });
// Check again after async boundary
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
return response.json();
}
// Usage
const controller = new AbortController();
// Start the request
const promise = fetchUserData('123', controller.signal);
// Cancel after 5 seconds
setTimeout(() => controller.abort(), 5000);
try {
const data = await promise;
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request was cancelled');
} else {
throw error;
}
}
Linking Abort Controllers (Parent-Child)
// Child signals should abort when parent aborts
function createLinkedController(
parentSignal?: AbortSignal
): AbortController {
const controller = new AbortController();
if (parentSignal) {
// If parent is already aborted, abort immediately
if (parentSignal.aborted) {
controller.abort(parentSignal.reason);
} else {
// Listen for parent abort
parentSignal.addEventListener(
'abort',
() => controller.abort(parentSignal.reason),
{ once: true }
);
}
}
return controller;
}
// Usage: hierarchical cancellation
async function fetchUserWithDetails(
userId: string,
signal?: AbortSignal
): Promise<UserWithDetails> {
const controller = createLinkedController(signal);
try {
const [user, orders, preferences] = await Promise.all([
fetchUser(userId, controller.signal),
fetchOrders(userId, controller.signal),
fetchPreferences(userId, controller.signal),
]);
return { user, orders, preferences };
} catch (error) {
// If one fails, cancel the others
controller.abort(error);
throw error;
}
}
AbortSignal.any() — Multiple Cancellation Sources
// Modern browsers support AbortSignal.any()
async function fetchWithMultipleCancellationSources(
url: string,
options: {
userSignal?: AbortSignal; // User clicked cancel
timeoutMs?: number; // Request timeout
pageSignal?: AbortSignal; // Page navigation
}
): Promise<Response> {
const signals: AbortSignal[] = [];
if (options.userSignal) {
signals.push(options.userSignal);
}
if (options.timeoutMs) {
signals.push(AbortSignal.timeout(options.timeoutMs));
}
if (options.pageSignal) {
signals.push(options.pageSignal);
}
// Abort if ANY signal fires
const combinedSignal = signals.length > 0
? AbortSignal.any(signals)
: undefined;
return fetch(url, { signal: combinedSignal });
}
// Polyfill for older environments
function abortSignalAny(signals: AbortSignal[]): AbortSignal {
const controller = new AbortController();
for (const signal of signals) {
if (signal.aborted) {
controller.abort(signal.reason);
return controller.signal;
}
signal.addEventListener(
'abort',
() => controller.abort(signal.reason),
{ once: true }
);
}
return controller.signal;
}
Making Non-Cancellable Operations Cancellable
// Third-party library doesn't support AbortSignal
import { thirdPartyFetch } from 'some-library';
async function cancellableThirdPartyFetch(
url: string,
signal?: AbortSignal
): Promise<Response> {
// Check before starting
signal?.throwIfAborted();
// Wrap in a race with the signal
return new Promise((resolve, reject) => {
// Set up abort handler
const onAbort = () => {
reject(new DOMException('Aborted', 'AbortError'));
};
if (signal) {
if (signal.aborted) {
return reject(new DOMException('Aborted', 'AbortError'));
}
signal.addEventListener('abort', onAbort, { once: true });
}
// Start the operation
thirdPartyFetch(url)
.then(resolve)
.catch(reject)
.finally(() => {
signal?.removeEventListener('abort', onAbort);
});
});
}
// Note: This doesn't truly cancel the underlying operation.
// The request continues; we just stop waiting for it.
// For true cancellation, the operation must support AbortSignal.
Structured Concurrency Patterns
Pattern 1: Error Propagation with Cleanup
interface TaskGroup<T> {
run<R>(task: (signal: AbortSignal) => Promise<R>): Promise<R>;
signal: AbortSignal;
}
async function withTaskGroup<T>(
fn: (group: TaskGroup<T>) => Promise<T>,
signal?: AbortSignal
): Promise<T> {
const controller = createLinkedController(signal);
const runningTasks: Promise<unknown>[] = [];
let firstError: Error | null = null;
const group: TaskGroup<T> = {
signal: controller.signal,
run: async <R>(task: (signal: AbortSignal) => Promise<R>): Promise<R> => {
const taskPromise = task(controller.signal).catch((error) => {
// First error aborts all other tasks
if (!firstError) {
firstError = error;
controller.abort(error);
}
throw error;
});
runningTasks.push(taskPromise);
return taskPromise;
},
};
try {
const result = await fn(group);
// Wait for all spawned tasks to complete
await Promise.allSettled(runningTasks);
// If any task failed, throw
if (firstError) {
throw firstError;
}
return result;
} catch (error) {
// Ensure cleanup
controller.abort(error);
await Promise.allSettled(runningTasks);
throw error;
}
}
// Usage
async function processOrder(orderId: string): Promise<OrderResult> {
return withTaskGroup(async (group) => {
// These all share the same cancellation scope
const user = await group.run((signal) =>
fetchUser(order.userId, signal)
);
const inventory = await group.run((signal) =>
checkInventory(order.items, signal)
);
// If payment fails, user fetch and inventory check are cancelled
const payment = await group.run((signal) =>
processPayment(order, signal)
);
return { user, inventory, payment };
});
}
Pattern 2: Concurrent Map with Controlled Parallelism
interface ConcurrentMapOptions {
concurrency?: number; // Max parallel operations
signal?: AbortSignal; // External cancellation
stopOnError?: boolean; // Fail fast or continue
}
async function concurrentMap<T, R>(
items: T[],
mapper: (item: T, index: number, signal: AbortSignal) => Promise<R>,
options: ConcurrentMapOptions = {}
): Promise<R[]> {
const { concurrency = 5, signal, stopOnError = true } = options;
const controller = createLinkedController(signal);
const results: R[] = new Array(items.length);
const errors: Array<{ index: number; error: Error }> = [];
let nextIndex = 0;
let activeCount = 0;
let resolveAll: () => void;
let rejectAll: (error: Error) => void;
const allDone = new Promise<void>((resolve, reject) => {
resolveAll = resolve;
rejectAll = reject;
});
async function runNext(): Promise<void> {
while (nextIndex < items.length && activeCount < concurrency) {
if (controller.signal.aborted) {
break;
}
const index = nextIndex++;
activeCount++;
try {
results[index] = await mapper(items[index], index, controller.signal);
} catch (error) {
errors.push({ index, error: error as Error });
if (stopOnError) {
controller.abort(error);
rejectAll(error as Error);
return;
}
} finally {
activeCount--;
}
// Schedule next item
if (!controller.signal.aborted) {
runNext();
}
}
// Check if all done
if (activeCount === 0 && (nextIndex >= items.length || controller.signal.aborted)) {
if (errors.length > 0 && !stopOnError) {
rejectAll(
new AggregateError(
errors.map(e => e.error),
`${errors.length} of ${items.length} operations failed`
)
);
} else {
resolveAll();
}
}
}
// Start initial batch
const initialBatch = Math.min(concurrency, items.length);
for (let i = 0; i < initialBatch; i++) {
runNext();
}
await allDone;
return results;
}
// Usage
const results = await concurrentMap(
userIds,
async (userId, index, signal) => {
const user = await fetchUser(userId, signal);
const details = await fetchUserDetails(userId, signal);
return { user, details };
},
{ concurrency: 10, stopOnError: false }
);
Pattern 3: Timeout Wrapper with Proper Cancellation
async function withTimeout<T>(
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
options: {
signal?: AbortSignal;
timeoutError?: Error;
} = {}
): Promise<T> {
const controller = createLinkedController(options.signal);
const timeoutId = setTimeout(() => {
controller.abort(
options.timeoutError ?? new Error(`Operation timed out after ${timeoutMs}ms`)
);
}, timeoutMs);
try {
const result = await operation(controller.signal);
clearTimeout(timeoutId);
return result;
} catch (error) {
clearTimeout(timeoutId);
// Distinguish timeout from other errors
if (controller.signal.aborted && error === controller.signal.reason) {
throw options.timeoutError ?? new Error(`Operation timed out after ${timeoutMs}ms`);
}
throw error;
}
}
// Usage
const data = await withTimeout(
async (signal) => {
const response = await fetch('/api/slow-endpoint', { signal });
return response.json();
},
5000,
{ timeoutError: new TimeoutError('API request timed out') }
);
Pattern 4: Retry with Backoff and Cancellation
interface RetryOptions {
maxAttempts?: number;
initialDelayMs?: number;
maxDelayMs?: number;
backoffMultiplier?: number;
signal?: AbortSignal;
shouldRetry?: (error: Error, attempt: number) => boolean;
onRetry?: (error: Error, attempt: number, delayMs: number) => void;
}
async function withRetry<T>(
operation: (signal: AbortSignal, attempt: number) => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const {
maxAttempts = 3,
initialDelayMs = 1000,
maxDelayMs = 30000,
backoffMultiplier = 2,
signal,
shouldRetry = () => true,
onRetry,
} = options;
const controller = createLinkedController(signal);
let lastError: Error;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
controller.signal.throwIfAborted();
return await operation(controller.signal, attempt);
} catch (error) {
lastError = error as Error;
// Don't retry if aborted
if (controller.signal.aborted) {
throw error;
}
// Don't retry if we've exhausted attempts
if (attempt >= maxAttempts) {
throw error;
}
// Check if error is retryable
if (!shouldRetry(lastError, attempt)) {
throw error;
}
// Calculate delay with exponential backoff
const delayMs = Math.min(
initialDelayMs * Math.pow(backoffMultiplier, attempt - 1),
maxDelayMs
);
// Add jitter (±25%)
const jitter = delayMs * 0.25 * (Math.random() * 2 - 1);
const actualDelay = Math.round(delayMs + jitter);
onRetry?.(lastError, attempt, actualDelay);
// Wait with cancellation support
await cancellableSleep(actualDelay, controller.signal);
}
}
throw lastError!;
}
async function cancellableSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
return reject(signal.reason);
}
const timeoutId = setTimeout(resolve, ms);
signal?.addEventListener(
'abort',
() => {
clearTimeout(timeoutId);
reject(signal.reason);
},
{ once: true }
);
});
}
// Usage
const data = await withRetry(
async (signal, attempt) => {
console.log(`Attempt ${attempt}...`);
return fetch('/api/flaky-endpoint', { signal }).then(r => r.json());
},
{
maxAttempts: 5,
shouldRetry: (error) => {
// Only retry on network errors or 5xx
return error.name === 'TypeError' ||
(error instanceof HttpError && error.status >= 500);
},
onRetry: (error, attempt, delay) => {
console.log(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
},
}
);
Common Failure Modes and Fixes
Failure Mode 1: Fire and Forget
// BAD: Promise created but not awaited
function handleClick(): void {
saveToServer(data); // Returns promise, not awaited
showSuccessMessage();
}
// What goes wrong:
// - Success message shows before save completes
// - If save fails, no error handling
// - Multiple clicks create multiple saves (race condition)
// GOOD: Proper async handling
async function handleClick(): Promise<void> {
try {
setLoading(true);
await saveToServer(data);
showSuccessMessage();
} catch (error) {
showErrorMessage(error.message);
} finally {
setLoading(false);
}
}
Failure Mode 2: Unhandled Promise Rejection in Loops
// BAD: forEach doesn't await
async function processItems(items: Item[]): Promise<void> {
items.forEach(async (item) => {
await processItem(item); // Returns promise, forEach ignores it
});
console.log('Done!'); // Logs immediately, processing continues in background
}
// GOOD: Use for...of or Promise.all
async function processItems(items: Item[]): Promise<void> {
for (const item of items) {
await processItem(item); // Sequential
}
console.log('Done!');
}
// OR parallel
async function processItems(items: Item[]): Promise<void> {
await Promise.all(items.map(item => processItem(item)));
console.log('Done!');
}
Failure Mode 3: Event Handler Promise Leaks
// BAD: Async event handlers can't be awaited
button.addEventListener('click', async () => {
await saveData(); // If this throws, it's an unhandled rejection
});
// GOOD: Wrap with error handling
button.addEventListener('click', () => {
saveData().catch((error) => {
console.error('Save failed:', error);
showErrorToast('Failed to save');
});
});
// BETTER: Centralized error handling
function safeHandler<T extends Event>(
handler: (event: T) => Promise<void>
): (event: T) => void {
return (event: T) => {
handler(event).catch((error) => {
reportError(error);
// Show generic error UI
});
};
}
button.addEventListener('click', safeHandler(async (event) => {
await saveData();
}));
Failure Mode 4: Race Conditions in State Updates
// BAD: Stale closure over state
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
async function handleSearch(searchQuery: string) {
const data = await fetchResults(searchQuery);
setResults(data); // Might be stale if another search started
}
useEffect(() => {
handleSearch(query);
}, [query]);
}
// What goes wrong:
// User types "ca" → request 1 starts
// User types "cat" → request 2 starts
// Request 2 completes with "cat" results
// Request 1 completes with "ca" results (slower)
// UI shows "ca" results for "cat" query
// GOOD: Abort previous requests
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
const controller = new AbortController();
fetchResults(query, controller.signal)
.then(setResults)
.catch((error) => {
if (error.name !== 'AbortError') {
console.error('Search failed:', error);
}
});
return () => controller.abort(); // Cleanup cancels previous
}, [query]);
}
// ALSO GOOD: Use a request ID to ignore stale responses
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const requestIdRef = useRef(0);
useEffect(() => {
const requestId = ++requestIdRef.current;
fetchResults(query).then((data) => {
// Only update if this is still the latest request
if (requestId === requestIdRef.current) {
setResults(data);
}
});
}, [query]);
}
Failure Mode 5: Missing Error Context
// BAD: Generic error handling
try {
await Promise.all([
fetchUser(userId),
fetchOrders(userId),
fetchRecommendations(userId),
]);
} catch (error) {
console.error('Something failed:', error);
// Which operation? What state is the system in?
}
// GOOD: Named promises with context
async function fetchDashboardData(userId: string): Promise<DashboardData> {
const operations = {
user: fetchUser(userId),
orders: fetchOrders(userId),
recommendations: fetchRecommendations(userId),
};
const results = await Promise.allSettled(Object.values(operations));
const keys = Object.keys(operations);
const data: Partial<DashboardData> = {};
const errors: Array<{ operation: string; error: Error }> = [];
results.forEach((result, index) => {
const operationName = keys[index];
if (result.status === 'fulfilled') {
data[operationName as keyof DashboardData] = result.value;
} else {
errors.push({ operation: operationName, error: result.reason });
}
});
if (errors.length > 0) {
// Log with context
console.error('Dashboard fetch partial failure:', {
failed: errors.map(e => e.operation),
succeeded: keys.filter(k => !(k in errors.map(e => e.operation))),
});
// Decide: throw or return partial data?
if (errors.some(e => e.operation === 'user')) {
// User is required
throw errors.find(e => e.operation === 'user')!.error;
}
// Others are optional, return what we have
}
return data as DashboardData;
}
Advanced: Async Iterators and Cancellation
Consuming Paginated APIs
async function* fetchAllPages<T>(
fetchPage: (cursor: string | null, signal: AbortSignal) => Promise<{
items: T[];
nextCursor: string | null;
}>,
signal?: AbortSignal
): AsyncGenerator<T, void, undefined> {
let cursor: string | null = null;
do {
signal?.throwIfAborted();
const { items, nextCursor } = await fetchPage(cursor, signal!);
for (const item of items) {
signal?.throwIfAborted();
yield item;
}
cursor = nextCursor;
} while (cursor !== null);
}
// Usage
const controller = new AbortController();
try {
for await (const user of fetchAllPages(
(cursor, signal) => api.getUsers({ cursor, limit: 100 }, signal),
controller.signal
)) {
await processUser(user);
// Can cancel at any time
if (shouldStop) {
controller.abort();
break;
}
}
} catch (error) {
if (error.name !== 'AbortError') {
throw error;
}
}
Streaming with Backpressure
async function* streamWithBackpressure<T>(
source: AsyncIterable<T>,
options: {
signal?: AbortSignal;
highWaterMark?: number;
onBackpressure?: () => void;
} = {}
): AsyncGenerator<T[], void, undefined> {
const { signal, highWaterMark = 100, onBackpressure } = options;
const buffer: T[] = [];
for await (const item of source) {
signal?.throwIfAborted();
buffer.push(item);
if (buffer.length >= highWaterMark) {
onBackpressure?.();
yield [...buffer];
buffer.length = 0;
}
}
// Yield remaining items
if (buffer.length > 0) {
yield buffer;
}
}
// Usage: batch processing with automatic chunking
for await (const batch of streamWithBackpressure(eventSource, {
highWaterMark: 50,
signal: controller.signal,
onBackpressure: () => console.log('Batching due to backpressure'),
})) {
await db.insertMany(batch);
}
The Complete Pattern: Async Task Manager
type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
interface Task<T> {
id: string;
status: TaskStatus;
result?: T;
error?: Error;
startedAt?: Date;
completedAt?: Date;
cancel: () => void;
}
interface TaskManagerOptions {
maxConcurrency: number;
signal?: AbortSignal;
onTaskComplete?: (task: Task<unknown>) => void;
onTaskError?: (task: Task<unknown>, error: Error) => void;
}
class TaskManager {
private tasks: Map<string, Task<unknown>> = new Map();
private queue: Array<() => void> = [];
private runningCount = 0;
private controller: AbortController;
constructor(private options: TaskManagerOptions) {
this.controller = createLinkedController(options.signal);
}
async submit<T>(
id: string,
operation: (signal: AbortSignal) => Promise<T>
): Promise<T> {
if (this.tasks.has(id)) {
throw new Error(`Task ${id} already exists`);
}
const taskController = createLinkedController(this.controller.signal);
const task: Task<T> = {
id,
status: 'pending',
cancel: () => taskController.abort(),
};
this.tasks.set(id, task as Task<unknown>);
return new Promise((resolve, reject) => {
const run = async () => {
task.status = 'running';
task.startedAt = new Date();
this.runningCount++;
try {
const result = await operation(taskController.signal);
task.status = 'completed';
task.result = result;
task.completedAt = new Date();
this.options.onTaskComplete?.(task as Task<unknown>);
resolve(result);
} catch (error) {
task.status = taskController.signal.aborted ? 'cancelled' : 'failed';
task.error = error as Error;
task.completedAt = new Date();
this.options.onTaskError?.(task as Task<unknown>, error as Error);
reject(error);
} finally {
this.runningCount--;
this.processQueue();
}
};
if (this.runningCount < this.options.maxConcurrency) {
run();
} else {
this.queue.push(run);
}
});
}
private processQueue(): void {
while (
this.queue.length > 0 &&
this.runningCount < this.options.maxConcurrency &&
!this.controller.signal.aborted
) {
const next = this.queue.shift()!;
next();
}
}
getTask(id: string): Task<unknown> | undefined {
return this.tasks.get(id);
}
cancelAll(): void {
this.controller.abort();
this.queue.length = 0;
}
async waitForAll(): Promise<void> {
const pending = [...this.tasks.values()].filter(
t => t.status === 'pending' || t.status === 'running'
);
if (pending.length === 0) return;
await Promise.allSettled(
pending.map(
t =>
new Promise<void>((resolve) => {
const check = () => {
if (t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled') {
resolve();
} else {
setTimeout(check, 10);
}
};
check();
})
)
);
}
}
// Usage
const manager = new TaskManager({
maxConcurrency: 5,
onTaskComplete: (task) => console.log(`Task ${task.id} completed`),
onTaskError: (task, error) => console.error(`Task ${task.id} failed:`, error),
});
// Submit tasks
await Promise.allSettled([
manager.submit('user-1', (signal) => fetchUser('1', signal)),
manager.submit('user-2', (signal) => fetchUser('2', signal)),
manager.submit('user-3', (signal) => fetchUser('3', signal)),
]);
// Or cancel all
manager.cancelAll();
Summary: The Rules
┌─────────────────────────────────────────────────────────────────────────────┐
│ JAVASCRIPT CONCURRENCY RULES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. EVERY async operation should accept an AbortSignal │
│ - Fetch, database calls, timers, everything │
│ - If a library doesn't support it, wrap it │
│ │
│ 2. EVERY Promise.all should consider what happens on partial failure │
│ - Do you need cleanup? │
│ - Should other operations continue? │
│ - Use Promise.allSettled when you need visibility │
│ │
│ 3. EVERY concurrent operation needs an owner │
│ - Who cancels it? │
│ - What happens when the parent scope exits? │
│ - Build structured concurrency patterns │
│ │
│ 4. NEVER fire and forget │
│ - Every promise needs error handling │
│ - Every promise needs a clear owner │
│ - Unhandled rejections should crash (in development) │
│ │
│ 5. ALWAYS consider race conditions │
│ - What if the user clicks twice? │
│ - What if a slow response arrives after a fast one? │
│ - What if the component unmounts mid-request? │
│ │
│ 6. PREFER explicit over implicit │
│ - Name your concurrent operations │
│ - Log what's in flight │
│ - Make cancellation visible │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
JavaScript's concurrency model is simple on the surface and treacherous in the details. The event loop handles scheduling. Promises handle sequencing. But you handle cancellation, cleanup, and error propagation.
Most production bugs aren't in the happy path. They're in what happens when the third of five concurrent operations fails, the user navigates away, and the cleanup function throws.
Design for that case first.
Concurrent operations in JavaScript are fire-and-forget by default. Structured concurrency makes them fire-and-track. Build the tracking yourself — the language won't do it for you.
What did you think?
Related Posts
March 19, 20262 min
Atomics, SharedArrayBuffer, and True Parallelism in JavaScript
March 10, 20263 min
Advanced Concurrency Patterns: Implementing Promise.scheduler, Semaphore, and ReadWriteLock in JavaScript
March 9, 20265 min