TypeScript `using` and `Symbol.asyncDispose`: Preventing DB Connection Leaks at the Language Level
PostgreSQL connection pool exhaustion blocking new requests is almost always caused by functions that omit finally from a try/finally block. With the TC39 Explicit Resource Management proposal officially included in ES2026 (ECMA-262 17th Edition), this problem can now be defended at the language level. Two declaration keywords — using and await using — automatically guarantee resource cleanup when their scope exits. Dispose runs regardless of whether an error occurs, an early return fires, or an exception is thrown.
Node.js 22+, Bun v1.3.12+, and Deno 1.38+ all support this syntax natively at the runtime level. Change just two lines in your TypeScript config and you can use it right now without any transpilation.
This article covers how using/await using works, how to implement the Symbol.dispose/Symbol.asyncDispose protocol, and real server-side code patterns ranging from PostgreSQL connection pools and MongoDB transactions to multi-resource composition.
Core Concepts
What Is Explicit Resource Management
The traditional try/finally pattern requires developers to write the finally block themselves. There is room for mistakes, and the code gets deeper the more nesting occurs. Explicit Resource Management elevates that responsibility to the language level.
// Traditional approach — if finally is omitted, the connection never returns to the pool
const client = await pool.connect();
try {
await client.query('BEGIN');
// ... business logic ...
await client.query('COMMIT');
} finally {
client.release();
}
// Explicit Resource Management approach
await using client = await acquireClient(pool);
await client.query('BEGIN');
// ... business logic ...
await client.query('COMMIT');
// client[Symbol.asyncDispose]() is called automatically when the block exits
// Guaranteed to run even if an error occursThe key point is that variables declared with using/await using have their dispose method called unconditionally the moment they leave scope.
The Two Declaration Keywords and Their Protocols
| Keyword | Target Resource | Method Called |
|---|---|---|
using |
Synchronous resource | [Symbol.dispose](): void |
await using |
Asynchronous resource | [Symbol.asyncDispose](): Promise<void> |
Resource classes simply implement the Disposable or AsyncDisposable interface respectively.
// Synchronous Disposable example
class ManagedFileHandle implements Disposable {
constructor(private fd: number) {}
[Symbol.dispose]() {
fs.closeSync(this.fd);
}
}
// Asynchronous AsyncDisposable example
class ManagedDbClient implements AsyncDisposable {
constructor(private client: PoolClient) {}
async [Symbol.asyncDispose]() {
this.client.release();
}
}TypeScript Configuration — Just Two Lines
You must explicitly add "esnext.disposable" to lib for Symbol.dispose/Symbol.asyncDispose types to be recognized correctly. If you already use esnext as a base (e.g. "lib": ["esnext"]), esnext.disposable is already included and no separate addition is needed. The reason explicit addition is required is that the recommended "es2022" base does not include esnext.disposable.
target must be "es2022" or higher so that TypeScript passes the syntax through as-is without down-transpiling it.
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "esnext.disposable"],
"module": "NodeNext"
}
}With target: "es2022", tsc only strips type information and leaves the using/await using syntax intact in the output file. The runtime interprets it directly.
Runtime support status is as follows.
| Runtime | using/await using syntax |
Symbol.dispose/asyncDispose |
|---|---|---|
| Node.js | 22+ | 18.18.0+ |
| Bun | v1.3.12+ | v1.3.12+ |
| Deno | 1.38+ | 1.37.0+ |
| Chrome | 127+ | 127+ |
| Firefox | 132+ | 132+ |
| Safari | 18+ | 18.3+ |
DisposableStack / AsyncDisposableStack
When you want to group multiple resources into a single unit, AsyncDisposableStack is useful. It executes dispose in LIFO (last-in, first-out) order, so resources with dependencies are cleaned up safely.
async function importData(srcUrl: string) {
await using stack = new AsyncDisposableStack();
const conn = stack.use(await acquireDbConnection()); // Register an AsyncDisposable implementation
const file = stack.use(await openRemoteStream(srcUrl)); // Register the same way
stack.defer(async () => await sendAuditLog('import.done')); // Arbitrary cleanup function
stack.adopt(rawHandle, (h) => h.close()); // Assign dispose to a non-Disposable
for await (const chunk of file.readable) {
await conn.query(/* ... */);
}
// When scope exits: sendAuditLog → file → conn are cleaned up automatically in that order
}Practical Application
1. Automatic PostgreSQL Connection Pool Release
node-postgres's PoolClient does not yet implement AsyncDisposable natively (under discussion in issue #3515). Creating one wrapper class lets you reuse it across all subsequent functions.
import { Pool, PoolClient } from 'pg';
class ManagedClient implements AsyncDisposable {
constructor(private client: PoolClient) {}
async query(sql: string, params?: unknown[]) {
return this.client.query(sql, params);
}
async [Symbol.asyncDispose]() {
this.client.release();
}
}
async function acquireClient(pool: Pool): Promise<ManagedClient> {
const client = await pool.connect();
return new ManagedClient(client);
}
// Call site — leak prevention without try/finally
async function getUser(pool: Pool, id: string) {
await using client = await acquireClient(pool);
const { rows } = await client.query('SELECT * FROM users WHERE id = $1', [id]);
return rows[0];
} // client[Symbol.asyncDispose]() → client.release() is called automatically hereEven if an error occurs, release() is guaranteed. Connection pool exhaustion can be defended at the language level without relying on code review.
2. Automatic Transaction Rollback Pattern
Transactions require a slightly different design. Because [Symbol.asyncDispose] cannot directly know whether the current function exited due to an error, the pattern is to manage commit status with a flag and branch inside dispose.
Session creation and transaction start are handled together in a factory function, making the wrapper's entry condition explicit.
import { MongoClient, ClientSession } from 'mongodb';
class ManagedTransaction implements AsyncDisposable {
private committed = false;
constructor(public session: ClientSession) {}
commit() {
this.committed = true;
}
async [Symbol.asyncDispose]() {
if (!this.committed) {
await this.session.abortTransaction();
}
await this.session.endSession();
}
}
function beginTransaction(client: MongoClient): ManagedTransaction {
const session = client.startSession();
session.startTransaction();
return new ManagedTransaction(session);
}
async function transfer(client: MongoClient, from: string, to: string, amount: number) {
const db = client.db();
await using tx = beginTransaction(client);
await db.collection('accounts').updateOne(
{ _id: from },
{ $inc: { balance: -amount } },
{ session: tx.session }
);
await db.collection('accounts').updateOne(
{ _id: to },
{ $inc: { balance: amount } },
{ session: tx.session }
);
await tx.session.commitTransaction();
tx.commit(); // If an error occurs before this line → dispose automatically rolls back
}3. Bun Built-in SQL — Use Directly Without a Wrapper
Bun v1.3+'s built-in SQL client's ReservedSQL natively implements AsyncDisposable, making it currently the only case where you can use await using directly without writing a separate wrapper class.
const sql = new Bun.SQL(process.env.DATABASE_URL!);
async function runInTransaction() {
await using reserved = await sql.reserve(); // ReservedSQL implements AsyncDisposable
await reserved`BEGIN`;
await reserved`INSERT INTO logs VALUES (${Date.now()})`;
await reserved`COMMIT`;
} // Connection is automatically returned when scope exits4. Multi-Resource Composition with AsyncDisposableStack
In functions where multiple resources are intertwined, AsyncDisposableStack is especially useful. Nesting individual try/finally blocks increases code depth; a stack lets you flatten them.
async function importData(srcUrl: string) {
await using stack = new AsyncDisposableStack();
const conn = stack.use(await acquireDbConnection());
const file = stack.use(await openRemoteStream(srcUrl));
stack.defer(async () => await sendAuditLog('import.done'));
// Cleaned up in LIFO order regardless of whether an error occurred
// Order: sendAuditLog → file → conn
for await (const chunk of file.readable) {
await conn.query(/* ... */);
}
}Pros and Cons
Advantages
| Item | Description |
|---|---|
| Leak prevention guarantee | dispose runs on all paths: errors, early returns, and exceptions |
| Reduced boilerplate | Removing nested try/finally improves readability of business logic |
| Language-level support | Dispose flow is meaningfully exposed in debuggers and stack traces |
| Composability | AsyncDisposableStack manages multiple resources as a single unit |
| Works without transpilation | Native support in Node.js 22+, Bun, and Deno |
Considerations
| Item | Details |
|---|---|
| Required tsconfig settings | Add "esnext.disposable" to lib; target: "es2022" or higher required |
| Immature ORM support | Drizzle, Prisma, node-postgres and other major libraries still require hand-written wrappers |
| commit/rollback design | Since dispose cannot know whether an error occurred, the flag pattern is mandatory |
SuppressedError handling |
If both the body and dispose throw, the two errors are bundled into one and re-thrown |
| Node.js version dependency | Below 22+, tsc down-compiles to try/finally — verify your legacy environment |
| Scope design caution | Declaring in an unintentionally narrow scope can trigger dispose too early |
Common Pitfalls in Practice
Ignoring SuppressedError makes error tracking difficult. If an error occurs in the using block body and another error occurs in dispose, the two errors are wrapped into a single SuppressedError.
Per the spec, the fields are as follows: SuppressedError.error is the error thrown during dispose (the suppressor), and SuppressedError.suppressed is the error that occurred first in the body (the suppressed one). If your existing error logger only prints .message, the suppressed error can disappear from logs entirely, so it is worth checking your error handler in advance.
try {
await using client = await acquireClient(pool);
throw new Error('Business logic error');
// Assume dispose also throws an error in this scenario
} catch (err) {
if (err instanceof SuppressedError) {
console.error('Dispose error:', err.error); // Error thrown during dispose (the suppressor)
console.error('Body error:', err.suppressed); // Error that occurred first in the body (the suppressed one)
} else {
console.error(err);
}
}Scoping too narrowly causes dispose to fire earlier than intended. Declaring await using inside an if block or for block triggers dispose when that block ends. If a resource needs to be shared across multiple blocks, declaring it at the function-level scope is safer.
Closing Thoughts
using/await using are not mere convenience syntax — they are a mechanism that defends against the chronic server bug of DB connection leaks at the language level. They are officially part of ES2026 and can be used right now in Node.js 22+, Bun v1.3.12+, and Deno 1.38+ without any transpilation. The fact that native ORM ecosystem support is still immature is unfortunate, but the wrapper class pattern covers it sufficiently.
The fastest way to get started is this order:
- tsconfig first — Add
"lib": ["es2022", "esnext.disposable"]and"target": "es2022". - Write one connection wrapper — Create a wrapper class that implements
[Symbol.asyncDispose]on top of your existing DB client and you can reuse it across the entire project. - Introduce a commit flag for transactions — Apply the
committedflag pattern first so that dispose can branch between rollback and normal session termination.
Once nested try/finally blocks are gone, the indentation depth of your function bodies decreases noticeably and the business logic moves to the foreground.
References
- TC39 Proposal: Explicit Resource Management (GitHub)
- JavaScript's New Superpower: Explicit Resource Management — V8 Blog
- Symbol.asyncDispose — MDN Web Docs
- TypeScript 5.2 Release Notes — Microsoft
- Using Explicit Resource Management with TypeScript and Postgres — r0b blog
- Resource management in TypeScript with the using keyword — LogRocket Blog
- Summary of the May 2025 TC39 Plenary — Igalia
- ECMA-262 16th Edition (June 2025)
- ECMAScript Committee Advances 3 Proposals to Stage 4 — The New Stack
- Symbol.asyncDispose support · node-postgres Issue #3515
- Drizzle ORM Transactions: TC39 explicit resource management · Issue #4546
- Deno PR #28119: Enable explicit resource management for JavaScript
- @nick/dispose — JSR (polyfill)
- MongoDB Transaction with TypeScript using keyword — GitHub Gist
- Bun.ReservedSQL API Reference