TypeScript using With AsyncDisposableStack: Coordinating Multi-Resource Teardown in Real Server Code
This article was written with the assistance of AI, under human supervision and review.
Most server-side resource leaks stem from incomplete teardown sequences when multiple async resources fail to clean up in production. A database connection closes but the Redis lock never releases. A file handle persists after the request completes because an upstream error short-circuited the cleanup chain. These failures happen silently until connection pools exhaust or disk quotas trigger alerts hours later.
The manual pattern teams reach for looks like nested try-finally blocks wrapping each resource. This approach breaks under real production conditions: errors in cleanup code prevent downstream resources from disposing, disposal order becomes implicit and fragile, and the code becomes unreadable past three resources. TypeScript's AsyncDisposableStack provides explicit coordination for multi-resource teardown with guaranteed disposal order and error aggregation that manual chains cannot match.
The solution coordinates resources through a stack that tracks every disposable and guarantees reverse-order teardown even when individual cleanup operations fail. A single await using stack = new AsyncDisposableStack() call establishes the coordinator. Each resource adoption via stack.adopt() registers the cleanup operation and ensures execution regardless of downstream failures. Errors during disposal aggregate into a SuppressedError chain that preserves all failure contexts instead of hiding them.
Key Takeaways
- AsyncDisposableStack coordinates multiple async resources through a single disposal point that guarantees reverse-order cleanup even when individual operations fail.
- Manual try-finally chains break under production error conditions because errors in cleanup code prevent downstream resources from disposing.
- The
adopt()method registers any resource with a cleanup function,defer()delays arbitrary async work until disposal, andmove()transfers ownership between stacks. - Disposal always executes in reverse registration order (LIFO), and errors aggregate into
SuppressedErrorchains that preserve every failure context. - AsyncDisposableStack shines in request-scoped resource management where database connections, distributed locks, and file handles must release atomically regardless of request outcome.
Understanding AsyncDisposableStack: Coordinating Multiple Disposables
AsyncDisposableStack implements the AsyncDisposable interface and provides three registration methods that teams use to build complex teardown sequences. Understanding how these methods differ determines which pattern fits a given production scenario.
The adopt() method takes a resource value and a cleanup function, returning the resource unchanged while registering the cleanup. This approach works for resources that do not implement Symbol.asyncDispose natively. The cleanup function receives the original resource value as its argument, enabling patterns like connection pool returns or lock releases that need the original reference.
const stack = new AsyncDisposableStack();
const connection = await pool.connect();
stack.adopt(connection, async (conn) => {
await conn.release();
});
The use() method registers an existing AsyncDisposable object directly, delegating to its Symbol.asyncDispose implementation. This method creates the most natural integration when resources already implement the disposal protocol. The stack calls [Symbol.asyncDispose]() on the registered object during its own disposal phase.
class ManagedLock implements AsyncDisposable {
async [Symbol.asyncDispose]() {
await this.release();
}
}
const lock = await acquireLock();
stack.use(lock); // Delegates to lock[Symbol.asyncDispose]()
The defer() method schedules arbitrary async work for the disposal phase without tying it to a specific resource value. This pattern handles cleanup operations that depend on closure state or need to run regardless of whether a resource allocation succeeded. Deferred functions execute in reverse registration order alongside adopted resources.
let tempFile: string | null = null;
stack.defer(async () => {
if (tempFile) {
await fs.unlink(tempFile);
}
});
tempFile = await createTempFile();
Disposal order follows last-in-first-out semantics strictly. A stack that adopts a database connection, then a Redis lock, then a file handle will close the file handle first, release the lock second, and return the connection last. This ordering matters because later resources often depend on earlier ones remaining available during their own cleanup.
The move() method transfers all registered resources from one stack to another, leaving the source stack in a disposed state. This operation enables patterns where a function builds a resource set and returns ownership to the caller. The caller's stack assumes responsibility for cleanup, and the original stack becomes inert.
async function setupResources() {
const stack = new AsyncDisposableStack();
stack.adopt(await openConnection(), closeConnection);
stack.adopt(await acquireLock(), releaseLock);
return stack.move(); // Transfers ownership, original stack now disposed
}
await using resources = await setupResources();
The disposed property indicates whether the stack has executed its disposal sequence. After disposal completes, any attempt to adopt new resources throws a ReferenceError. This guard prevents resource registration after cleanup has started, which would create impossible teardown guarantees.
Real Server Scenario: Database Connection, Redis Lock, and File Handle Cleanup
A production API endpoint that processes uploaded files demonstrates the coordination problem AsyncDisposableStack solves. The operation needs a database transaction for metadata writes, a Redis lock to prevent concurrent processing of the same file, and a temporary file handle for staging the upload. All three resources must release atomically when the request completes or fails.
The manual approach chains try-finally blocks, creating nested error-handling contexts that become unmaintainable and fail to guarantee complete cleanup when intermediate operations error.
async function processUpload(fileData: Buffer, metadata: UploadMetadata) {
const connection = await pool.connect();
try {
await connection.query('BEGIN');
const lockKey = `upload:${metadata.id}`;
const lock = await redisClient.lock(lockKey, 30000);
try {
const tempPath = `/tmp/upload-${Date.now()}`;
await fs.writeFile(tempPath, fileData);
try {
// Process file...
await connection.query('INSERT INTO uploads ...', [metadata]);
await connection.query('COMMIT');
} finally {
await fs.unlink(tempPath); // If this throws, lock never releases
}
} finally {
await lock.release(); // If this throws, connection never returns
}
} finally {
connection.release();
}
}
The failure mode here is subtle but expensive. An error in fs.unlink() prevents the lock release from executing. The lock holds for its 30-second TTL while the connection returns to the pool. Concurrent uploads to the same file block until the lock expires. The temporary file persists on disk. None of these failures surface as errors because the outer finally blocks never execute.
AsyncDisposableStack eliminates nested scopes and guarantees all cleanups execute regardless of individual failures.
async function processUpload(fileData: Buffer, metadata: UploadMetadata) {
await using stack = new AsyncDisposableStack();
const connection = await pool.connect();
stack.adopt(connection, async (conn) => {
await conn.query('ROLLBACK').catch(() => {}); // Best-effort rollback
conn.release();
});
await connection.query('BEGIN');
const lockKey = `upload:${metadata.id}`;
const lock = await redisClient.lock(lockKey, 30000);
stack.adopt(lock, async (l) => await l.release());
const tempPath = `/tmp/upload-${Date.now()}`;
await fs.writeFile(tempPath, fileData);
stack.defer(async () => {
await fs.unlink(tempPath).catch(() => {}); // Ensure unlink attempt
});
// Process file...
await connection.query('INSERT INTO uploads ...', [metadata]);
await connection.query('COMMIT');
// Stack disposes in reverse order: unlink -> release lock -> release connection
}
When the function scope exits, the stack disposes the deferred unlink first, then releases the lock, then returns the connection. An error in any cleanup operation does not prevent the others from executing. The stack aggregates errors into a SuppressedError chain that preserves each failure context for logging and debugging.
This pattern extends naturally to more complex scenarios. A batch processing job that opens multiple file handles can adopt each one immediately after opening, ensuring cleanup even if later opens fail. A distributed transaction coordinator can adopt compensating actions as defer() calls, guaranteeing rollback attempts regardless of commit success.
AsyncDisposableStack vs Manual try-finally Chains: Error Handling and Guarantees
The comparison between manual try-finally chains and AsyncDisposableStack reveals fundamental differences in error propagation and cleanup guarantees that affect production reliability.
Manual chains execute cleanup in the opposite order of their nesting structure, but only if no earlier cleanup throws. An error in an inner finally block prevents outer finally blocks from executing, leaving upstream resources leaked. This failure mode creates silent resource exhaustion that manifests hours after the triggering error.
AsyncDisposableStack guarantees every registered cleanup executes regardless of other cleanup failures. When a disposal operation throws, the stack catches the error, continues disposing remaining resources, and aggregates all errors into a SuppressedError instance. The primary error becomes the thrown exception, and subsequent errors attach as the suppressed property chain.
async function demonstrateErrorAggregation() {
await using stack = new AsyncDisposableStack();
stack.defer(async () => {
throw new Error('First cleanup failed');
});
stack.defer(async () => {
throw new Error('Second cleanup failed');
});
// When scope exits, both errors aggregate:
// SuppressedError: Second cleanup failed
// suppressed: Error: First cleanup failed
}
The error aggregation pattern preserves every failure context instead of hiding secondary errors behind the first exception. Logging infrastructure can traverse the suppressed chain to capture complete cleanup failure sequences. This visibility matters in production where a database timeout might trigger a cascade of downstream cleanup failures that need individual investigation.
Manual chains require explicit error handling in each finally block to prevent error propagation from stopping cleanup. This approach adds boilerplate and still lacks the aggregation semantics that AsyncDisposableStack provides automatically.
// Manual error handling to approximate AsyncDisposableStack behavior
let connectionError: Error | null = null;
try {
// ... use connection
} finally {
try {
connection.release();
} catch (error) {
connectionError = error as Error;
}
try {
await lock.release();
} catch (error) {
if (connectionError) {
// Manual error aggregation - verbose and error-prone
}
}
}
The guarantee difference extends to disposal order predictability. Manual chains execute cleanup in reverse nesting order only when exception handling respects that order. A refactor that reorders try blocks changes cleanup semantics implicitly. AsyncDisposableStack makes disposal order explicit through registration order, decoupling cleanup sequence from lexical structure.
Production Patterns: adopt(), defer(), and move() for Complex Teardown Flows
Real production code requires patterns beyond simple resource adoption. Conditional resource acquisition, ownership transfer, and cleanup that depends on operation results all appear in server codebases. AsyncDisposableStack's three registration methods handle these scenarios through composition.
The adopt() pattern works when a resource comes from a factory or pool that needs a custom cleanup function. Database connections from pools, Redis clients from connection managers, and HTTP clients with specific shutdown sequences all fit this pattern. The cleanup function receives the resource value, enabling access to methods that the resource type exposes.
async function withDatabaseTransaction<T>(
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
await using stack = new AsyncDisposableStack();
const client = await pool.connect();
stack.adopt(client, async (c) => {
await c.query('ROLLBACK').catch(() => {});
c.release();
});
await client.query('BEGIN');
const result = await fn(client);
await client.query('COMMIT');
return result;
}
The defer() pattern handles cleanup that depends on conditional execution or closure state. A temporary directory that only needs deletion if file processing succeeded uses defer() because the decision logic happens after resource creation. Metrics reporting at scope exit, cache invalidation, and webhook notifications all fit the defer() pattern.
async function processWithTempDir(files: File[]) {
await using stack = new AsyncDisposableStack();
const tempDir = await fs.mkdtemp('/tmp/process-');
let shouldKeep = false;
stack.defer(async () => {
if (!shouldKeep) {
await fs.rm(tempDir, { recursive: true });
}
});
// Process files...
if (allFilesValid) {
shouldKeep = true;
}
}
The move() pattern transfers ownership when a function builds a resource set and returns it to the caller. This pattern appears in factory functions that set up complex resource graphs where cleanup responsibility must transfer atomically. Without move(), the factory would need to either dispose resources before returning (preventing caller usage) or leak cleanup responsibility.
A practical example shows move() in a database migration runner that opens connections for multiple databases and returns coordinated cleanup to the migration orchestrator.
async function setupMigrationEnvironment() {
const stack = new AsyncDisposableStack();
const primary = await connectPrimary();
stack.adopt(primary, async (conn) => await conn.close());
const replica = await connectReplica();
stack.adopt(replica, async (conn) => await conn.close());
const lockClient = await redis.connect();
stack.adopt(lockClient, async (client) => await client.quit());
return {
primary,
replica,
lockClient,
cleanup: stack.move(), // Caller owns cleanup
};
}
async function runMigrations() {
const env = await setupMigrationEnvironment();
await using cleanup = env.cleanup; // Adopts transferred resources
// Use env.primary, env.replica, env.lockClient
// All resources dispose when cleanup scope exits
}
Combining these patterns handles scenarios like request-scoped resource pools where a middleware establishes a stack, handlers adopt resources during request processing, and the middleware disposes everything when the response completes. The stack provides the coordination point that makes this pattern reliable.
Common Pitfalls: Disposal Order, Async Timing, and What dispose() Actually Returns
Teams adopting AsyncDisposableStack encounter three failure modes that stem from incorrect assumptions about disposal semantics. Understanding these pitfalls prevents production bugs.
The disposal order pitfall happens when code assumes registration order matches cleanup needs but later resources depend on earlier ones remaining available. A file handle that writes to a database connection must close before the connection returns to the pool, requiring adoption in the opposite order of their dependency relationship.
The solution reverses adoption order relative to dependency order. Adopt the connection first, then adopt resources that depend on it. Disposal executes in reverse, closing dependent resources before the connection they rely on.
// Wrong - connection closes before file disposal needs it
await using stack = new AsyncDisposableStack();
const connection = await pool.connect();
stack.adopt(connection, async (c) => c.release());
const file = await openFile(connection);
stack.adopt(file, async (f) => {
await f.writeMetadata(connection); // connection already released!
await f.close();
});
// Correct - file closes before connection it depends on
await using stack = new AsyncDisposableStack();
const connection = await pool.connect();
const file = await openFile(connection);
stack.adopt(file, async (f) => {
await f.writeMetadata(connection); // connection still available
await f.close();
});
stack.adopt(connection, async (c) => c.release());
The async timing pitfall stems from expecting await using disposal to complete before the next statement executes. Disposal happens at scope exit, which means all code in the current scope runs before any disposal begins. A common mistake is adopting a resource, using it, then trying to access it after the scope where it was adopted has exited.
let connection: PoolClient;
{
await using stack = new AsyncDisposableStack();
connection = await pool.connect();
stack.adopt(connection, async (c) => c.release());
// Disposal has not happened yet
}
// Disposal completes here - connection released
await connection.query('SELECT 1'); // Error: connection already released
The correct pattern keeps resource usage within the same scope as the stack that manages it. Resources that need to outlive their allocation scope require a parent stack that covers the longer lifetime.
The disposal return value pitfall happens when code expects await stack.dispose() to return a value indicating success or failure. The dispose() method returns void and throws on error, following the same semantics as Symbol.asyncDispose. Code that needs to distinguish between successful disposal and disposal errors must wrap the call in try-catch.
// Wrong - dispose() returns void, not a status
const result = await stack.dispose();
if (result.success) { /* ... */ }
// Correct - handle disposal errors via exception
try {
await stack.dispose();
// All cleanups succeeded
} catch (error) {
if (error instanceof SuppressedError) {
// Access error.suppressed for secondary failures
}
}
A related pitfall treats the stack as reusable after disposal. Once stack.dispose() completes, the stack enters a disposed state where stack.disposed === true. Any attempt to adopt new resources throws ReferenceError. Code that needs multiple cleanup phases must create separate stacks for each phase.
Integration with Express Middleware and Request-Scoped Resource Management
Express middleware demonstrates AsyncDisposableStack's value in request-scoped resource coordination where multiple resources must release when the response completes regardless of success or error. A middleware that establishes a stack, populates it during request processing, and guarantees disposal when the response finishes solves the resource leak problem that affects long-running servers.
The pattern attaches an AsyncDisposableStack to the request object in early middleware, allowing handlers to adopt resources throughout the request lifecycle. A final middleware or error handler ensures disposal when the response closes.
import express from 'express';
import { AsyncDisposableStack } from 'node:async_hooks';
interface ResourceRequest extends express.Request {
resources: AsyncDisposableStack;
}
function resourceMiddleware(
req: express.Request,
res: express.Response,
next: express.NextFunction
) {
const resourceReq = req as ResourceRequest;
resourceReq.resources = new AsyncDisposableStack();
res.on('finish', async () => {
await resourceReq.resources.dispose().catch((error) => {
console.error('Resource cleanup failed:', error);
});
});
next();
}
app.use(resourceMiddleware);
app.post('/api/upload', async (req: express.Request, res: express.Response) => {
const resourceReq = req as ResourceRequest;
// Adopt database connection
const connection = await pool.connect();
resourceReq.resources.adopt(connection, async (c) => c.release());
// Acquire distributed lock
const lockKey = `upload:${req.body.id}`;
const lock = await redisClient.lock(lockKey, 30000);
resourceReq.resources.adopt(lock, async (l) => await l.release());
// Create temporary file
const tempPath = `/tmp/upload-${Date.now()}`;
await fs.writeFile(tempPath, req.body.data);
resourceReq.resources.defer(async () => {
await fs.unlink(tempPath).catch(() => {});
});
// Process upload...
await processUpload(connection, tempPath);
res.json({ status: 'success' });
// Resources dispose when response finishes
});
This pattern handles error cases automatically. An exception in processUpload() triggers Express error handling, which still emits the finish event that triggers disposal. The database connection returns to the pool, the lock releases, and the temporary file deletes regardless of request success.
The approach extends to WebSocket connections, streaming responses, and long-polling endpoints where resources must remain alive for extended periods. A connection upgrade handler can adopt the socket and related resources into a stack that disposes when the socket closes.
import { WebSocketServer } from 'ws';
wss.on('connection', async (ws) => {
await using stack = new AsyncDisposableStack();
const connection = await pool.connect();
stack.adopt(connection, async (c) => c.release());
const subscription = await pubsub.subscribe('updates');
stack.adopt(subscription, async (s) => await s.unsubscribe());
ws.on('message', async (data) => {
const result = await connection.query('INSERT INTO messages ...', [data]);
await pubsub.publish('updates', result.rows[0]);
});
subscription.on('message', (msg) => {
ws.send(JSON.stringify(msg));
});
ws.on('close', async () => {
await stack.dispose();
});
});
The WebSocket handler adopts both the database connection and the pub/sub subscription. When the client disconnects, the close event triggers stack disposal. Both resources release atomically, preventing the leak that manual cleanup chains create when one operation fails.
Teams using AsyncDisposableStack in middleware report elimination of resource leak incidents that previously required server restarts. The explicit disposal point and guaranteed cleanup execution solve the class of bugs that stem from incomplete teardown sequences in error paths.
Frequently Asked Questions
What happens if dispose() is called multiple times on the same AsyncDisposableStack?
The first dispose() call executes all cleanup operations and sets stack.disposed to true. Subsequent calls return immediately without executing any cleanup. This idempotency prevents double-disposal errors but means code cannot reset or reuse a disposed stack.
Can AsyncDisposableStack handle synchronous disposal operations or does everything need to be async?
AsyncDisposableStack only supports async disposal functions. Synchronous cleanup operations need wrapping in an async function or using DisposableStack instead. Mixing sync and async disposal in the same stack is not supported by the current API.
Does the order of adopt(), use(), and defer() calls affect disposal order or do they all execute in reverse registration order together?
All registration methods (adopt, use, defer) add to a single disposal sequence in registration order. Disposal executes this sequence in reverse regardless of which registration method was used. A defer() registered before an adopt() will dispose after the adopted resource.
How does AsyncDisposableStack interact with TypeScript's using keyword for synchronous resources?
AsyncDisposableStack requires await using because it implements Symbol.asyncDispose. The synchronous using keyword works only with DisposableStack and Symbol.dispose. Mixing sync and async disposal requires separate stacks for each protocol.
What happens to resources if an error occurs during stack.move() or if the moved stack is never disposed?
The move() operation transfers all resources atomically. If the source stack has not disposed, the returned stack owns all resources. If the returned stack never disposes, the resources leak. The source stack becomes disposed after move() regardless of what happens to the returned stack.
Conclusion: When AsyncDisposableStack Beats Custom Cleanup Logic
AsyncDisposableStack solves the multi-resource coordination problem that manual try-finally chains cannot handle reliably. When a codebase accumulates database connections, distributed locks, file handles, and network clients that must all release atomically, the manual approach creates nested scopes that fail under error conditions. AsyncDisposableStack provides guaranteed disposal order, error aggregation, and explicit coordination that production code requires.
The pattern applies beyond simple resource cleanup. Request-scoped resource management in web servers, distributed transaction coordination in microservices, and batch processing pipelines with multiple data sources all benefit from the dispose-on-exit semantics that await using provides. The adoption patterns through adopt(), defer(), and move() handle conditional cleanup, ownership transfer, and closure-based teardown that real production code encounters daily.
That covers the essential patterns for coordinating multi-resource teardown with AsyncDisposableStack. Apply these in production server code and the difference in resource leak prevention will be immediate. For more on TypeScript's resource management features, see TypeScript using: Explicit Resource Management and 10 TypeScript Utility Types for Bulletproof Code. Teams building MCP servers will find similar patterns in Build an MCP Server in TypeScript with Claude.






Top comments (0)