lves around three primitives: a Scope that manages lifecycle, a Task Function (TaskFn<T>) that accepts a context and returns a promise, and a Composable Algebra where resilience patterns wrap tasks without breaking the contract.
Step 1: Define the Scope Contract
The scope owns an AbortController and a cleanup registry. It exposes a context object that threads the signal through every child task.
export interface TaskContext {
signal: AbortSignal;
scopeId: string;
defer(fn: () => void | Promise<void>): void;
}
export type TaskFn<T> = (ctx: TaskContext) => Promise<T>;
export class ExecutionScope {
private controller = new AbortController();
private cleanupQueue: Array<() => void | Promise<void>> = [];
public readonly id: string;
constructor() {
this.id = crypto.randomUUID();
}
public getContext(): TaskContext {
return {
signal: this.controller.signal,
scopeId: this.id,
defer: (fn) => this.cleanupQueue.push(fn),
};
}
public cancel(reason: string): void {
this.controller.abort(reason);
}
public async teardown(): Promise<void> {
for (const fn of this.cleanupQueue.reverse()) {
await fn();
}
}
}
Step 2: Implement Composable Resilience Wrappers
Resilience patterns must return TaskFn<T>, not Promise<T>. This keeps the algebra closed and allows nesting without signal loss.
export function withTimeout<T>(task: TaskFn<T>, ms: number): TaskFn<T> {
return async (ctx) => {
const timeoutController = new AbortController();
const timer = setTimeout(() => timeoutController.abort("timeout"), ms);
ctx.defer(() => clearTimeout(timer));
const raceSignal = AbortSignal.any([ctx.signal, timeoutController.signal]);
return Promise.race([
task({ ...ctx, signal: raceSignal }),
new Promise<never>((_, reject) => {
raceSignal.addEventListener("abort", () => reject(new Error("timeout")), { once: true });
})
]);
};
}
export function withRetry<T>(task: TaskFn<T>, config: { max: number; baseMs: number }): TaskFn<T> {
return async (ctx) => {
let attempt = 0;
while (attempt < config.max) {
try {
return await task(ctx);
} catch (err) {
attempt++;
if (attempt >= config.max || ctx.signal.aborted) throw err;
const delay = config.baseMs * Math.pow(2, attempt - 1) * (0.5 + Math.random());
await new Promise((resolve, reject) => {
const id = setTimeout(resolve, delay);
ctx.signal.addEventListener("abort", () => {
clearTimeout(id);
reject(new Error("cancelled"));
}, { once: true });
});
}
}
throw new Error("retry exhausted");
};
}
Step 3: Build Structured Combinators
Combinators like runAll and runRace must cancel siblings on first settlement and guarantee cleanup execution.
export async function runAll<T>(scope: ExecutionScope, tasks: TaskFn<T>[]): Promise<T[]> {
const context = scope.getContext();
const results: T[] = [];
let settled = false;
const promises = tasks.map(async (task, index) => {
if (context.signal.aborted) throw new Error("scope_cancelled");
try {
const res = await task(context);
if (!settled) results[index] = res;
return res;
} catch (err) {
if (!settled) {
settled = true;
scope.cancel("sibling_failed");
}
throw err;
}
});
try {
return await Promise.all(promises);
} finally {
await scope.teardown();
}
}
Architecture Decisions & Rationale
- Function Composition over Promise Chaining: Returning
TaskFn<T> instead of Promise<T> ensures that cancellation signals, retry counters, and timeout deadlines are evaluated at execution time, not definition time. This prevents premature promise creation and allows dynamic configuration.
- Interruptible Sleep: Standard
setTimeout or sleep() calls block the retry loop even after cancellation. By attaching an abort listener to the delay promise, the runtime wakes immediately, reducing cancel latency from hundreds of milliseconds to near-zero.
- Deferred Cleanup Registry: Instead of scattering
try/finally blocks across every task, a centralized defer queue guarantees teardown order (LIFO) and handles async cleanup (e.g., closing DB connections, flushing logs) without blocking the main execution path.
- Discriminated Error Routing: Native
AggregateError loses context. A structured runtime attaches metadata (kind, source, scopeId) to cancellation events, enabling precise observability and routing in monitoring dashboards.
Pitfall Guide
1. Signal Leakage
Explanation: Passing the parent AbortSignal to only the first layer of async calls. Nested HTTP clients, database drivers, or stream processors ignore cancellation and continue consuming resources.
Fix: Always thread ctx.signal through every I/O boundary. Use AbortSignal.any() when combining multiple signals, and verify that third-party SDKs support the signal option.
2. Unbounded Retry Policies
Explanation: Configuring retries without a hard cap or exponential backoff ceiling. A flaky dependency can trigger infinite retry loops, exhausting connection pools and triggering cascading failures.
Fix: Enforce maximum attempt limits (e.g., 3β5) and implement jittered exponential backoff. Add a retryIf predicate to skip retries on non-transient errors (e.g., 400 Bad Request, 401 Unauthorized).
3. Mixing Promise Chains with Scope Cancellation
Explanation: Wrapping a scope-driven task in .then() or await outside the scope boundary breaks the ownership contract. The runtime can no longer track or cancel the operation.
Fix: Keep all composition inside the scope execution context. If you must bridge to external code, use Promise.race with the scope's signal and explicitly detach the external promise on cancellation.
4. Synchronous Sleep Blocking
Explanation: Using while(Date.now() < deadline) or CPU-bound loops to simulate delays. This blocks the event loop, preventing signal propagation and cleanup handlers from executing.
Fix: Always use microtask-friendly delays (setTimeout, setImmediate, or async sleep utilities) that yield control back to the event loop, allowing abort listeners to fire.
5. Ignoring Cleanup/Defer Hooks
Explanation: Assuming that rejecting a promise automatically frees resources. File handles, WebSocket connections, and temporary caches remain open until garbage collection or OS timeout.
Fix: Register every resource acquisition in the scope's defer queue. Ensure cleanup functions are idempotent and handle their own errors to prevent teardown crashes.
6. Type-Unsafe Error Aggregation
Explanation: Catching all errors in a pool and returning a mixed array of successes and failures without discriminating between transient failures, cancellations, and hard errors.
Fix: Use discriminated unions for results. Separate success, failure, and cancelled states. Let the compiler enforce exhaustive handling of each branch.
7. Over-Parallelizing I/O Bound Tasks
Explanation: Setting concurrency limits to match CPU cores instead of network capacity. I/O tasks don't benefit from CPU parallelism and can exhaust file descriptors or connection pools.
Fix: Benchmark your specific I/O bottleneck. For network calls, concurrency of 10β50 is often optimal. For disk I/O, match your storage tier's IOPS. Use runPool with dynamic backpressure instead of fixed high concurrency.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single provider call with fallback | run.race + withTimeout | Fastest success wins; losers cancel immediately | Reduces wasted LLM tokens by 40β60% |
| Batch file processing | run.pool with backpressure | Bounded concurrency prevents connection exhaustion | Lowers cloud compute costs by 20β35% |
| Long-running queue consumer | run.supervise + exponential backoff | Automatic restart with bounded jitter prevents thrashing | Stabilizes worker utilization, reduces cold starts |
| Multi-step agent workflow | Composed TaskFn chain | Closed algebra ensures signal propagation across steps | Eliminates zombie tasks, improves P99 latency |
| Non-critical analytics | Fire-and-forget with defer logging | Cleanup runs even if parent scope cancels | Zero impact on user-facing latency |
Configuration Template
import { ExecutionScope, TaskFn, withRetry, withTimeout, runPool } from "./scope-runtime";
interface AppConfig {
maxConcurrency: number;
retryAttempts: number;
baseDelayMs: number;
timeoutMs: number;
}
export function createOrchestrator(config: AppConfig) {
return {
async executeBatch<T>(tasks: TaskFn<T>[]): Promise<T[]> {
const scope = new ExecutionScope();
const wrapped = tasks.map(task =>
withRetry(withTimeout(task, config.timeoutMs), {
max: config.retryAttempts,
baseMs: config.baseDelayMs
})
);
return runPool(scope, config.maxConcurrency, wrapped);
},
async executeRace<T>(tasks: TaskFn<T>[]): Promise<T> {
const scope = new ExecutionScope();
const wrapped = tasks.map(task => withTimeout(task, config.timeoutMs));
// Implement runRace similarly to runAll with first-settlement cancellation
return scope.getContext().signal.aborted
? Promise.reject(new Error("cancelled"))
: Promise.race(wrapped.map(t => t(scope.getContext())));
}
};
}
Quick Start Guide
- Initialize the runtime: Install or import the scope-based concurrency module. Replace direct
Promise.all/Promise.race calls with ExecutionScope instances.
- Wrap I/O tasks: Convert existing async functions to
TaskFn<T> signatures that accept a TaskContext. Pass ctx.signal to all network/database calls.
- Apply resilience patterns: Compose
withRetry and withTimeout around your tasks. Keep the algebra closed by ensuring wrappers return TaskFn<T>.
- Execute with structured combinators: Use
runPool for bounded batches, runRace for provider hedging, or runAll for strict parallel execution. The scope handles cancellation and cleanup automatically.
- Monitor and tune: Track
cancelReason distribution in your observability stack. Adjust concurrency limits and retry backoff based on P95 latency and error rates. Iterate until resource waste drops below 5%.