d settles deterministically.
Step-by-Step Implementation
- Define the Scope Boundary: Create a class that manages child tasks, cancellation state, and cleanup handlers.
- Bind to Request Lifecycle: Attach HTTP/WebSocket disconnect events to trigger scope termination.
- Spawn Child Operations: Register all downstream work within the scope, passing a cooperative cancellation signal.
- Aggregate Deterministically: Wait for all children to complete or cancel, ensuring no promise hangs.
- Execute Teardown: Run registered cleanup handlers in reverse registration order.
Architecture Decisions & Rationale
- Why a Scope Class? Native
Promise.all() lacks cancellation coordination. A scope provides explicit ownership, tracks active children, and enforces deterministic teardown.
- Why AbortSignal? It is the standard cooperative cancellation mechanism in modern JavaScript runtimes. Passing it to SDKs (OpenAI, Pinecone, etc.) ensures downstream libraries stop work gracefully.
- Why Reverse-Order Cleanup? Resources are typically acquired in a specific order (e.g., DB connection β stream β audit log). Teardown should reverse this to prevent dangling references or locked resources.
- Why Deterministic Aggregation? Without it, a cancelled child might leave a hanging promise, causing memory leaks or unhandled rejection warnings. The scope must guarantee settlement.
New Code Example
import { Request, Response } from "express";
import { createOpenAI } from "@openai/sdk";
import { VectorClient } from "./vector-db";
import { ToolExecutor } from "./tool-runtime";
class ExecutionScope {
private controller: AbortController;
private children: Map<string, Promise<unknown>>;
private cleanupHooks: Array<() => Promise<void>>;
private isCancelled: boolean;
constructor() {
this.controller = new AbortController();
this.children = new Map();
this.cleanupHooks = [];
this.isCancelled = false;
}
get signal(): AbortSignal {
return this.controller.signal;
}
spawn<T>(name: string, executor: (signal: AbortSignal) => Promise<T>): Promise<T> {
if (this.isCancelled) {
throw new Error(`Scope cancelled. Cannot spawn "${name}".`);
}
const task = executor(this.controller.signal);
this.children.set(name, task);
task.finally(() => this.children.delete(name));
return task;
}
onCleanup(handler: () => Promise<void>): void {
this.cleanupHooks.unshift(handler);
}
async cancel(reason: string): Promise<void> {
if (this.isCancelled) return;
this.isCancelled = true;
this.controller.abort(reason);
for (const hook of this.cleanupHooks) {
await hook().catch(err => console.error(`Cleanup error: ${err.message}`));
}
}
async settle(): Promise<void> {
const pending = Array.from(this.children.values());
if (pending.length > 0) {
await Promise.allSettled(pending);
}
}
}
// Route Implementation
app.post("/ai/chat", async (req: Request, res: Response) => {
const scope = new ExecutionScope();
req.on("close", () => {
scope.cancel("client_disconnected").finally(() => scope.settle());
});
try {
const openai = createOpenAI({ apiKey: process.env.OPENAI_KEY });
const vectorDB = new VectorClient(process.env.VECTOR_ENDPOINT);
const tools = new ToolExecutor();
const llmStream = scope.spawn("llm-generation", async (signal) => {
return openai.chat.completions.create({
model: "gpt-4.1",
stream: true,
messages: req.body.messages,
signal,
});
});
const vectorSearch = scope.spawn("embedding-retrieval", async (signal) => {
return vectorDB.similaritySearch({
query: req.body.query,
topK: 5,
signal,
});
});
const toolExecution = scope.spawn("external-tools", async (signal) => {
return tools.run(req.body.tools, { signal });
});
scope.onCleanup(async () => {
await vectorDB.close();
console.info("Resources released.");
});
const [stream, context, toolResults] = await Promise.all([
llmStream,
vectorSearch,
toolExecution,
]);
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content) res.write(content);
}
res.end();
} catch (err: any) {
if (err.name === "AbortError") {
res.status(499).json({ error: "Request cancelled by client" });
} else {
res.status(500).json({ error: err.message });
}
} finally {
await scope.settle();
}
});
Why This Works
The scope enforces ownership. Every spawned task is tracked. The AbortController provides a single source of truth for cancellation state. When the client disconnects, scope.cancel() fires, propagating the signal to all active SDKs and custom async functions. Promise.allSettled() guarantees no promise hangs. Cleanup hooks run deterministically. The architecture transforms cancellation from a manual convention into a structural guarantee.
Pitfall Guide
1. Fire-and-Forget Task Spawning
Explanation: Developers spawn background tasks without attaching them to a scope or tracking their lifecycle. These tasks continue running after the request ends, consuming memory and compute.
Fix: Never spawn async work outside a tracked boundary. Use scope.spawn() or equivalent registration mechanisms. If a task must outlive the request, route it through a message queue or worker pool with explicit lifecycle management.
2. Ignoring AbortSignal in Downstream SDKs
Explanation: Passing signal to the LLM but forgetting it in vector search, HTTP clients, or custom fetch calls. The SDK continues executing, wasting tokens and API quota.
Fix: Audit every async call. Ensure all HTTP clients, database drivers, and SDK wrappers accept and respect AbortSignal. Use wrapper functions that enforce signal propagation.
3. Mixing Synchronous Cleanup with Async Teardown
Explanation: Running synchronous database closes or file handle releases inside async cleanup hooks without awaiting, or vice versa. This causes race conditions and resource leaks.
Fix: Standardize cleanup as async. Use Promise.allSettled() for parallel cleanup, or sequential for...of loops for ordered teardown. Never mix sync and async teardown in the same handler chain.
4. Race Conditions in Cancellation Handlers
Explanation: Attaching multiple req.on("close") listeners that each trigger independent cancellation logic. This causes duplicate teardown, double-cleanup, or unhandled promise rejections.
Fix: Centralize cancellation in a single scope method. Use idempotent cancellation flags (isCancelled) to prevent duplicate execution. Attach only one lifecycle listener per request.
5. Assuming HTTP close Equals Full Teardown
Explanation: Relying solely on req.on("close") or res.on("finish") to trigger cleanup. These events fire at different times depending on the protocol (HTTP/1.1 vs HTTP/2 vs WebSocket), leading to premature or delayed teardown.
Fix: Map protocol-specific events to a unified onDisconnect handler. For WebSockets, listen to close and error. For HTTP/2, handle aborted and close. Normalize these into a single scope cancellation trigger.
6. Over-Cancelling Critical Background Work
Explanation: Cancelling everything when the client drops, including audit logs, metrics emission, or billing writes. This creates data gaps and compliance issues.
Fix: Separate request-bound work from system-bound work. Use a secondary scope or dedicated worker for telemetry, billing, and audit trails. Only cancel user-facing compute when the client disconnects.
7. Missing Fallback for Partial Completions
Explanation: Assuming all tasks complete successfully. When cancellation occurs mid-execution, partial state (e.g., half-written cache, incomplete tool results) causes inconsistent responses or downstream errors.
Fix: Implement idempotent operations where possible. Use transactional writes or versioned caches. Return graceful degradation responses when cancellation interrupts critical paths. Log partial completion states for observability.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Real-time AI chat with streaming | Request-bound scope | Guarantees token stream stops on disconnect, prevents GPU waste | High savings on API/GPU costs |
| Background batch embedding job | Message queue + worker pool | Decouples lifecycle from HTTP request, enables retries | Neutral (infrastructure cost shifts to queue) |
| WebSocket multiplayer session | Session-bound scope | Ties game loop, state sync, and tool calls to session lifecycle | Prevents zombie game state and CPU leaks |
| Server-side rendering with data fetching | Request-bound scope | Stops DB queries and API calls when client navigates away | Reduces database connection pressure |
| Long-running agent orchestration | Hierarchical scopes + checkpointing | Enables granular cancellation per agent step, preserves state | Moderate (adds complexity, prevents runaway compute) |
Configuration Template
// scope-manager.ts
import { AsyncLocalStorage } from "async_hooks";
export class ScopeManager {
private static storage = new AsyncLocalStorage<ExecutionScope>();
static run<T>(executor: () => Promise<T>): Promise<T> {
const scope = new ExecutionScope();
return this.storage.run(scope, async () => {
try {
return await executor();
} finally {
await scope.settle();
}
});
}
static current(): ExecutionScope {
const scope = this.storage.getStore();
if (!scope) throw new Error("No active execution scope found.");
return scope;
}
}
// Usage in middleware
app.use((req, res, next) => {
const scope = new ExecutionScope();
req.on("close", () => scope.cancel("client_left"));
res.on("finish", () => scope.settle());
ScopeManager.storage.run(scope, next);
});
Quick Start Guide
- Install dependencies: Ensure your project uses Node.js 18+ and has
@types/node for async_hooks support.
- Create the scope class: Copy the
ExecutionScope implementation into your utilities directory. Add tracking, signal propagation, and cleanup hooks.
- Wrap your route handler: Replace direct
await calls with scope.spawn(). Pass scope.signal to all SDKs and async functions.
- Attach lifecycle listeners: Bind
req.on("close") to scope.cancel() and res.on("finish") to scope.settle().
- Validate in staging: Simulate client disconnects using
curl --max-time 2 or browser dev tools. Monitor logs for AbortError and verify post_cancel_work_ms approaches zero.