Skip to content

Automatic Resource Management

When to use

Use this pattern when you need guaranteed cleanup of a resource — temporary directories, database connections, file handles, server instances — even when errors occur. The pattern prevents resource leaks by coupling resource creation and disposal into a single callback-scoped API.

The pattern

A factory function defineManagedResource takes a create function and a dispose function, then returns an async "using" function. The caller passes a callback that receives the resource. Disposal runs in a finally block, so it executes whether the callback succeeds or throws.

Using a managed resource

The usingTempDir function takes an async callback that receives a temporary directory path. The directory exists for the duration of the callback and is automatically deleted afterward — even if the callback throws:

javascript
const result = await usingTempDir(async (dir) => {
  // dir exists here
  await writeFile(join(dir, "data.json"), payload);
  return await processDir(dir);
});
// dir has been deleted, even if processDir threw

Defining a managed resource

Call defineManagedResource with a create function and a dispose function. It returns an async function that handles the create → use → dispose lifecycle:

javascript
import { rm } from "node:fs/promises";
import { temporaryDirectory } from "./temp.js";
import { defineManagedResource } from "./using.js";

export const usingTempDir = defineManagedResource(
  async () => temporaryDirectory(),
  async (tempDir) => {
    await rm(tempDir, { recursive: true });
  },
);

Test isolation

Each test gets its own temporary directory that is automatically cleaned up:

javascript
import { describe, it, expect } from "vitest";
import { usingTempDir } from "./temp.js";

describe("glob", () => {
  it("finds matching files", async () => {
    await usingTempDir(async (dir) => {
      await writeFile(join(dir, "a.txt"), "");
      await writeFile(join(dir, "b.js"), "");
      const matches = await glob("*.txt", { cwd: dir });
      expect(matches).toEqual(["a.txt"]);
    });
  });
});

Lifecycle ordering

The create → use → dispose sequence is guaranteed:

javascript
const log = [];

const usingResource = defineManagedResource(
  async () => {
    log.push("create");
    return { id: 1 };
  },
  async () => {
    log.push("dispose");
  },
);

await usingResource(async (resource) => {
  log.push("use");
});

console.log(log);
// => ["create", "use", "dispose"]

Error handling

When the callback throws, disposal still runs and the original error propagates:

javascript
try {
  await usingTempDir(async (dir) => {
    throw new Error("something failed");
  });
} catch (error) {
  // error.message === "something failed"
  // tempDir has already been cleaned up
}

Implementing defineManagedResource()

The factory returns an async function that creates the resource, runs the caller's callback inside a try block, and disposes the resource in a finally block — guaranteeing cleanup whether the callback succeeds or throws.

javascript
export function defineManagedResource(
  createResource,
  disposeResource,
) {
  return async function usingManagedResource(useFn) {
    const resource = await createResource();
    try {
      return await useFn(resource);
    } finally {
      await disposeResource(resource);
    }
  };
}

Trade-offs

  • Pros: Guaranteed cleanup via finally, composable (define once, reuse everywhere), return values pass through, works with any async resource.
  • Cons: Callback nesting — each managed resource adds one level of indentation. Not as ergonomic as the TC39 Explicit Resource Management proposal (using keyword with Symbol.dispose).
  • Versus TC39 using: The defineManagedResource pattern works in all current runtimes today. When using declarations and Symbol.dispose reach broad support, consider migrating. The callback pattern remains useful for cases where the dispose function needs async cleanup (Symbol.asyncDispose).