Explicit Resource Management in TypeScript and Node.js 24: How We Use using, await using and DisposableStack
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗Key takeaways
using and await using call Symbol.dispose or Symbol.asyncDispose when the enclosing block exits, whether it returns, breaks or throws, and dispose several resources in reverse declaration order.
Node.js 24 parses using natively, and TypeScript 5.2 and later downlevel it into try/finally helpers for older targets.
Node.js built-ins already implement the disposer protocol: FileHandle, http.Server, net.Server, timers, ChildProcess and fs.promises.mkdtempDisposable().
Disposers receive no error argument, so a disposable database transaction must roll back unless commit() was called explicitly.
using ties a resource to a lexical scope, so it releases too early for a stream returned from a Route Handler and does not belong in a React component body.
Why are we moving cleanup code to using?
At Devya we are moving resource cleanup from hand-written try/finally blocks to explicit resource management, because a leaked connection almost always comes from one error path that skipped a release() call. Explicit resource management puts the cleanup inside the resource itself, so every exit path runs it. Since Node.js 24 became the active LTS line, the syntax runs without a compiler on our Node.js services, and we now use it for database clients, transactions, temporary directories and test fixtures.
What do using and await using actually do?
A using declaration binds a block-scoped constant and calls its Symbol.dispose method when the block exits, by any path. Explicit resource management is the TC39 language feature behind it: a resource is any object with a Symbol.dispose method (a disposable) or a Symbol.asyncDispose method (an async disposable). await using calls and awaits Symbol.asyncDispose, so it is allowed only inside async functions and at the top level of ES modules.
A typical example is a function that converts an upload. It declares await using on a temporary directory from mkdtempDisposable(), then await using on the file handle returned by open(). When the function returns or throws, the file closes first and the temporary directory is removed second, with no finally block anywhere.
Three rules matter in practice. Resources are disposed in reverse declaration order. A value without a disposer method throws a TypeError at the declaration line, while null and undefined are allowed and skipped. And inside a using scope our team writes return await, because a bare return of a promise exits the scope and disposes the resources before that promise settles.
Node.js 24 ships V8 13.6, which parses the syntax natively, including in .ts files run through Node's built-in type stripping. TypeScript has supported using since 5.2: with target esnext it emits using unchanged, and with a lower target it rewrites each block into try/finally helper calls. The type declarations come from the esnext lib or the narrower esnext.disposable lib.
Which Node.js APIs are already disposable?
Node.js built-ins gained disposer methods before the syntax shipped, so using works on them without wrappers. Node.js 20 and 22 define Symbol.dispose, Symbol.asyncDispose and most of these methods but cannot parse using; Node.js 24 is the first LTS line that does both.
FileHandle from fs/promises: await using closes the file (added in v20.4.0).
fs.promises.mkdtempDisposable(): await using removes the temporary directory and its contents (added in v24.4.0).
fs.Dir from opendir(): either form closes the directory handle (added in v24.1.0).
http.Server and net.Server: await using calls server.close() (added in v20.4.0 and v20.5.0).
Timeout and Immediate: using cancels the timer (added in v20.5.0).
ChildProcess: using calls kill with SIGTERM (added in v20.5.0).
server.close() stops accepting connections and resolves only after active ones end, so we use await using on servers in tests, not for production shutdown, which still needs an explicit drain timeout. A disposable timer makes a tidy guard: using on a setTimeout that logs a slow-operation warning cancels the warning automatically when the work finishes first.
How do we make a pooled database client and a transaction disposable?
We wrap the client returned by pool.connect() in a small factory that adds a disposer. Our examples use node-postgres, but the same shape works for any driver with an acquire and release pair.
Our connect(pool) helper returns the client plus a Symbol.dispose method that calls client.release(). Our beginTransaction(pool) helper acquires a client, runs BEGIN, and returns the client, a commit() method that runs COMMIT and sets a committed flag, and a Symbol.asyncDispose method. That disposer releases the client after a commit; otherwise it runs ROLLBACK and releases the client, and if the rollback itself fails it releases the client with the error so the pool destroys the connection.
The design rule we follow is commit explicitly, roll back by default. A disposer is called with no arguments, so it cannot tell whether the block threw. The transaction object tracks the committed flag itself, and every exit that did not reach commit() rolls back: an exception, an early return, or a forgotten commit(). A money transfer then reads as one await using declaration, two UPDATE statements and one commit() call.
When is using better than try/finally, a callback helper or DisposableStack?
using wins whenever a function acquires more than one resource, because each try/finally nests one level deeper while using declarations stay flat.
try/finally: best for one resource or runtimes without the syntax; it nests per resource and is easy to miss on one path.
Callback helpers such as withClient(fn): best for libraries that must control the whole lifetime; they add callback nesting and make early returns awkward.
using and await using: best for a fixed set of resources in one scope; they end with the block and cannot hand a resource to the caller.
DisposableStack and AsyncDisposableStack: best for a variable number of resources and for factories that transfer ownership; they add one more object to reason about.
DisposableStack and AsyncDisposableStack are built-in containers that collect cleanups with use(), adopt() and defer() and run them in reverse order. Their move() method transfers every registered cleanup to a new stack. Our test-app factory uses this: it registers a temporary directory, starts an HTTP server and waits for the listening event, registers the server only after it listens, then calls move() so the caller owns both. If listening fails, the stack removes the directory before the error propagates.
What happens when the code and a disposer both throw?
When the block throws and a disposer throws too, JavaScript throws a SuppressedError: its error property holds the disposer's error and its suppressed property holds the original error from the block. With several failing disposers the errors nest, one SuppressedError per failure.
Error trackers group by the top-level error, so a failed ROLLBACK on a dead connection can hide the real cause. Our error logger walks the chain through the suppressed property and reports the original cause first, followed by every disposer error. We also keep disposers quiet: they return early when the resource is already closed and throw only when something is left leaked or in an unknown state.
Where does using break in Next.js and React code?
using releases a resource when the function's block ends, not when the work the function started ends. A Next.js Route Handler that returns a streamed Response is the clearest case: the handler returns before the body is read, so a client declared with using in the handler goes back to the pool while the stream still needs it.
The fix is to move the declaration into the async generator that produces the stream. A generator's scope stays open until it finishes, and ReadableStream.from() calls the generator's return() when the consumer cancels, which runs the disposer.
React has the same boundary. A using declaration in a component body disposes the resource at the end of that render, so subscriptions and connections that live as long as the component stay in useEffect cleanup. In an async Server Component, using is safe only when every query finishes before the component returns its JSX.
For client bundles we let the compiler downlevel using instead of shipping the native syntax, because browser support is not yet universal. The compiled helpers still need Symbol.dispose and Symbol.asyncDispose at runtime, and DisposableStack is a runtime class no compiler can downlevel, so older browsers need a polyfill such as core-js.
FAQ
Q: Can I use the using keyword on Node.js 20 or 22?
A: Not natively. Node.js 20 and 22 define Symbol.dispose, Symbol.asyncDispose and the disposer methods on built-in APIs, but they cannot parse using; compile with TypeScript 5.2 or later to a lower target and the output runs on both.
Q: Does a disposer know whether the block threw an error?
A: No. Symbol.dispose and Symbol.asyncDispose methods are called with no arguments, so success-only work such as committing a transaction must be an explicit call before the block ends, with rollback as the default.
Q: What happens if I declare using on an object without a disposer?
A: The declaration throws a TypeError immediately, at the using line rather than at scope exit. null and undefined are accepted and skipped, which makes optional resources easy to declare.
Q: Should using replace graceful shutdown for the connection pool and the HTTP server?
A: No. A connection pool or a production server lives as long as the process, not a lexical scope, so it still closes from a SIGTERM handler with a drain timeout; using fits per-request, per-job and per-test resources.
Further Reading
Backend Engineering
Reading PostgreSQL EXPLAIN ANALYZE: How We Find the Missing Index Behind a Slow Node.js Endpoint
We stopped adding PostgreSQL indexes by guesswork. Our process: find the costliest query in pg_stat_statements, read its EXPLAIN (ANALYZE, BUFFERS) plan for rows that are read and thrown away, and add the one composite, partial or covering index the plan asks for, built with CREATE INDEX CONCURRENTLY.
Backend Engineering
Integration Tests Against Real PostgreSQL with Testcontainers: How We Run One Container per Run and a Database per Test File
Mocked repositories kept tests green while the SQL underneath was wrong. We moved our Node.js integration tests onto real PostgreSQL with Testcontainers: one container per Vitest run, migrations applied once to a template database, a cloned database per test file, the settings that keep it fast, and how it runs in GitHub Actions.