c runner that wraps each combinator with production safeguards.
Step 1: Define the Execution Contract
Start by establishing a strict TypeScript interface that enforces predictable input/output shapes. This prevents type drift and makes combinator usage auditable.
interface AsyncTask<T> {
id: string;
executor: () => Promise<T>;
timeoutMs?: number;
}
interface SettlementResult<T> {
taskId: string;
status: 'fulfilled' | 'rejected';
value?: T;
error?: Error;
}
Step 2: Implement Timeout Wrappers
Raw promises lack built-in cancellation. In production, you must enforce timeouts to prevent indefinite memory retention.
function withTimeout<T>(executor: () => Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Task timed out after ${ms}ms`)), ms);
executor()
.then((value) => {
clearTimeout(timer);
resolve(value);
})
.catch((err) => {
clearTimeout(timer);
reject(err);
});
});
}
Step 3: Build Combinator-Specific Orchestrators
Instead of calling combinators directly, wrap them in domain-specific functions that handle cleanup and logging.
Latency-First Routing (race)
async function executeFastest<T>(tasks: AsyncTask<T>[]): Promise<T> {
const wrapped = tasks.map(task =>
withTimeout(task.executor, task.timeoutMs ?? 5000)
);
try {
return await Promise.race(wrapped);
} catch (err) {
// Note: Remaining promises in the race continue executing.
// In production, attach abort controllers or cleanup handlers here.
throw new Error(`All routes failed or timed out: ${(err as Error).message}`);
}
}
Resilient Batch Processing (allSettled)
async function executeWithPartialFailure<T>(tasks: AsyncTask<T>[]): Promise<SettlementResult<T>[]> {
const wrapped = tasks.map(task =>
withTimeout(task.executor, task.timeoutMs ?? 10000)
.then(value => ({ taskId: task.id, status: 'fulfilled' as const, value }))
.catch(error => ({ taskId: task.id, status: 'rejected' as const, error }))
);
return Promise.allSettled(wrapped).then(results =>
results.map(r => r as SettlementResult<T>)
);
}
Fallback Success Routing (any)
async function executeFirstSuccess<T>(tasks: AsyncTask<T>[]): Promise<T> {
const wrapped = tasks.map(task =>
withTimeout(task.executor, task.timeoutMs ?? 8000)
);
try {
return await Promise.any(wrapped);
} catch (err) {
if (err instanceof AggregateError) {
throw new Error(`No fallback succeeded. Failures: ${err.errors.map(e => e.message).join(', ')}`);
}
throw err;
}
}
Architecture Decisions & Rationale
- Explicit Timeout Enforcement: Raw combinators do not cancel underlying operations. By wrapping each executor with
withTimeout, we guarantee that pending network requests or heavy computations do not leak memory. In production, pair this with AbortController for fetch requests.
- Pre-Settlement Mapping in
allSettled: Instead of relying on the default {status, value/reason} shape, we map successes and failures before passing them to the combinator. This ensures type consistency and prevents runtime type guards from scattering across the codebase.
- AggregateError Handling:
Promise.any throws a native AggregateError when all promises reject. Catching and unwrapping this error provides actionable debugging context rather than a generic rejection.
- Idempotent Task IDs: Attaching
id to each task enables correlation in distributed tracing systems (e.g., OpenTelemetry, Datadog). This is critical for debugging race conditions in production.
Pitfall Guide
1. The Silent Memory Leak in Promise.race
Explanation: Promise.race settles immediately when the first promise settles, but it does not cancel the remaining promises. If those promises hold references to large payloads, open sockets, or event listeners, they continue executing in the background, delaying garbage collection.
Fix: Always pair race with cancellation tokens or abort controllers. For fetch operations, pass an AbortSignal to each request and call abort() on the non-winning promises.
2. Misinterpreting AggregateError Structure
Explanation: Developers often assume Promise.any rejects with a standard Error. It actually throws an AggregateError containing an errors array. Attempting to access .message directly yields a generic string, obscuring root causes.
Fix: Check err instanceof AggregateError and iterate over err.errors to extract individual failure reasons. Log them with task correlation IDs.
3. Non-Promise Value Coercion
Explanation: Passing synchronous values or undefined to combinators does not throw. The engine automatically wraps them in resolved promises. This can mask configuration errors where an async function accidentally returns a raw value.
Fix: Validate inputs before passing to combinators. Use TypeScript's Promise<T> typing strictly, and add runtime guards if accepting dynamic payloads from external systems.
4. Unhandled Rejection Cascades
Explanation: When using allSettled or any, developers sometimes forget to attach .catch() or try/catch blocks to the combinator itself. If the combinator rejects (e.g., any when all fail), the rejection bubbles up unhandled, crashing Node.js processes or triggering browser error overlays.
Fix: Always wrap combinator calls in explicit error boundaries. In Node.js, configure process.on('unhandledRejection') as a safety net, but never rely on it for business logic.
5. Timeout Misalignment Across Combinators
Explanation: Setting identical timeouts for all tasks in a race or any scenario creates artificial contention. If two promises resolve at 4990ms and 5000ms, the combinator's behavior becomes unpredictable due to event loop scheduling variance.
Fix: Stagger timeouts based on expected latency profiles. Use exponential backoff or jitter for retry-heavy tasks. Document timeout expectations in API contracts.
6. Over-Concurrency Without Backpressure
Explanation: Passing hundreds of promises to a combinator floods the event loop and exhausts OS file descriptors or connection pools. This causes TCP queue buildup and degrades performance for unrelated services.
Fix: Implement concurrency limits using a queue pattern or libraries like p-limit. Batch large arrays into chunks of 10-50 before passing to combinators.
7. Assuming Combinators Are Cancellable
Explanation: Native promises lack a .cancel() method. Once passed to a combinator, you cannot stop execution without external state management. This leads to wasted compute and unexpected side effects (e.g., duplicate database writes).
Fix: Design executors to be idempotent or support cancellation via AbortController. Never assume the combinator will clean up underlying resources.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Multi-region API fetch where any successful response is acceptable | Promise.any | Minimizes latency by returning the first successful response; tolerates regional outages | Low compute cost; reduces average response time by 20-35% |
| Bulk data ingestion where partial failures must be logged but not block the batch | Promise.allSettled | Guarantees all tasks complete; isolates errors without aborting the pipeline | Medium compute cost; increases memory usage temporarily during batch execution |
| Real-time UI update requiring the fastest available data source | Promise.race | Delivers immediate results; ideal for caching strategies or CDN fallbacks | High memory risk if remaining promises are not cancelled; requires explicit cleanup |
| Critical transaction requiring all steps to succeed | Promise.all (not a combinator focus, but baseline) | Fails fast on first error; ensures atomicity | Low overhead; unsuitable for fault-tolerant architectures |
Configuration Template
Copy this TypeScript utility into your infrastructure layer. It provides a standardized, production-ready wrapper for combinator execution with built-in telemetry hooks.
// async-orchestrator.ts
export type TaskStatus = 'fulfilled' | 'rejected';
export interface OrchestratorTask<T> {
id: string;
run: () => Promise<T>;
timeout?: number;
}
export interface OrchestratorResult<T> {
taskId: string;
status: TaskStatus;
data?: T;
error?: Error;
}
export class AsyncOrchestrator {
private static wrapWithTimeout<T>(task: OrchestratorTask<T>): Promise<T> {
const timeout = task.timeout ?? 5000;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timeout: ${task.id}`)), timeout);
task.run()
.then(val => { clearTimeout(timer); resolve(val); })
.catch(err => { clearTimeout(timer); reject(err); });
});
}
static async race<T>(tasks: OrchestratorTask<T>[]): Promise<T> {
const wrapped = tasks.map(t => this.wrapWithTimeout(t));
return Promise.race(wrapped);
}
static async allSettled<T>(tasks: OrchestratorTask<T>[]): Promise<OrchestratorResult<T>[]> {
const wrapped = tasks.map(t =>
this.wrapWithTimeout(t)
.then(data => ({ taskId: t.id, status: 'fulfilled' as TaskStatus, data }))
.catch(error => ({ taskId: t.id, status: 'rejected' as TaskStatus, error }))
);
return Promise.allSettled(wrapped).then(res => res as OrchestratorResult<T>[]);
}
static async any<T>(tasks: OrchestratorTask<T>[]): Promise<T> {
const wrapped = tasks.map(t => this.wrapWithTimeout(t));
try {
return await Promise.any(wrapped);
} catch (err) {
if (err instanceof AggregateError) {
throw new Error(`All tasks failed: ${err.errors.map(e => e.message).join(' | ')}`);
}
throw err;
}
}
}
Quick Start Guide
- Install TypeScript & Configure Strict Mode: Ensure
strict: true in tsconfig.json to catch type mismatches early. Combinators rely on precise generic typing.
- Define Task Contracts: Create
OrchestratorTask<T> interfaces for each async workflow. Attach unique IDs and reasonable timeouts (e.g., 3000ms for cache, 8000ms for external APIs).
- Replace Raw Combinator Calls: Swap direct
Promise.race() or Promise.allSettled() invocations with AsyncOrchestrator.race() or AsyncOrchestrator.allSettled(). This injects timeout safety and standardized error shapes.
- Integrate Cancellation for Network Tasks: For
race and any, pass AbortController signals to fetch/axios calls. Call controller.abort() on non-winning promises to free connections.
- Validate in Staging: Run load tests with 50-100 concurrent tasks. Monitor heap usage, event loop lag, and error rates. Adjust timeouts and concurrency limits based on telemetry before promoting to production.