Resource Management in JavaScript

Avatar of Hemanta SundarayHemanta Sundaray

While building software, we work with resources like files, databases, web sockets and timers. These resources must be opened before use and closed after use to avoid resource leaks (like open file handles or dangling database connections). Let’s see this in action using a concrete example.

Inside resource-leak-demo.ts, add the following code:

resource-leak-demo.ts
import { mkdtemp, open, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
let handlesOpened = 0;
let handlesClosed = 0;
function printStatus() {
const handlesLeaked = handlesOpened - handlesClosed;
console.log(
` Handles opened: ${handlesOpened} | Handles closed: ${handlesClosed} | Handles leaked: ${handlesLeaked}`,
);
}
// Create a temporary directory and then create data.txt inside it with some content
const tmpDir = await mkdtemp(join(tmpdir(), "leak-demo-"));
const filePath = join(tmpDir, "data.txt");
await writeFile(filePath, "some important data", "utf-8");
// Read the data inside data.txt and throw an error if the data contains the term "important"
async function readData(filePath: string): Promise<string> {
const handle = await open(filePath, "r");
handlesOpened++;
const content = await handle.readFile("utf-8");
if (content.includes("important")) {
throw new Error("Failed to process: unsupported format");
}
await handle.close();
handlesClosed++;
return content;
}
// Run readData 5 times
async function main() {
for (let i = 1; i <= 5; i++) {
try {
await readData(filePath);
console.log(`Call ${i}: success`);
} catch {
console.log(`Call ${i}: error caught and "handled"`);
}
printStatus();
console.log();
}
// Delete the temporary directory
await rm(tmpDir, { recursive: true, force: true });
}
await main();

Before we walk through the code, let’s understand what a file handle is. When you open a file, the operating system generates a unique identifier for that specific file access session. Your program then uses this identifier for all subsequent file operations (reading, writing, seeking and so on). The important point here is that the file handle remains valid until you explicitly close it.

The operating system only allows each process a limited number of these file handle slots. Every file you open takes one slot. If you keep opening files and never closing them, you eventually use up all available slots. At that point, the next open() call fails, even if it is for a completely unrelated file in a completely unrelated part of your application. This is called a resource leak.

Now, let’s understand what we are doing in the code snippet above. We create a temporary directory using mkdtemp with the prefix leak-demo-. Then we create a file named data.txt inside this directory and write the string "some important data" into it.

Next, look at the readData function. It opens the file using open(), which gives us a file handle. It then reads the file content using handle.readFile(). After reading, it checks whether the content contains the word "important". If it does, the function throws an error. Finally, it closes the file handle with handle.close().

Inside the main function, we call readData five times in a loop. Each call is wrapped in a try/catch, so the error is caught and the program keeps running. We also call printStatus() after each call to track how many handles have been opened, closed and leaked.

Run the file. You will see the following output in the terminal:

Terminal
Call 1: error caught and "handled"
Handles opened: 1 | Handles closed: 0 | Handles leaked: 1
Call 2: error caught and "handled"
Handles opened: 2 | Handles closed: 0 | Handles leaked: 2
Call 3: error caught and "handled"
Handles opened: 3 | Handles closed: 0 | Handles leaked: 3
Call 4: error caught and "handled"
Handles opened: 4 | Handles closed: 0 | Handles leaked: 4
Call 5: error caught and "handled"
Handles opened: 5 | Handles closed: 0 | Handles leaked: 5

As you can see, every time the readData function ran, it opened a file handle but did not close any of them. As a result, at the end of the 5 executions, we have 5 file handles open to the data.txt file.

You might wonder, why did the file handle not get closed when we explicitly closed it using handle.close()? That is because the error was thrown before the handle.close() line. When the error is thrown, execution jumps out of the function immediately. The handle.close() line never runs. The file handle leaks.

5 leaked file handles is not a big deal. But in a real application, multiply this number by hundreds or thousands of calls and eventually the operating system refuses to open any more files. Your entire application crashes — Not because of the original error, but because of the leaked file handles.

So, is there a solution in JavaScript that can help us close the file handle regardless of whether the operation succeeds or fails?

The try/finally Fix

In JavaScript, we can add a finally block after a try block. The finally block runs whether the try block succeeds or throws. This makes it a good place to put cleanup logic like closing file handles.

Replace the existing readData function with the following:

async function readData(filePath: string): Promise<string> {
const handle = await open(filePath, "r");
handlesOpened++;
try {
const content = await handle.readFile("utf-8");
if (content.includes("important")) {
throw new Error("Failed to process: unsupported format");
}
return content;
} finally {
await handle.close();
handlesClosed++;
}
}

Now run the file. You will see the following output in the terminal:

Terminal
Call 1: error caught and "handled"
Handles opened: 1 | Handles closed: 1 | Handles leaked: 0
Call 2: error caught and "handled"
Handles opened: 2 | Handles closed: 2 | Handles leaked: 0
Call 3: error caught and "handled"
Handles opened: 3 | Handles closed: 3 | Handles leaked: 0
Call 4: error caught and "handled"
Handles opened: 4 | Handles closed: 4 | Handles leaked: 0
Call 5: error caught and "handled"
Handles opened: 5 | Handles closed: 5 | Handles leaked: 0

We do not have any leaked file handles. The finally block ensures that handle.close() runs no matter what. If readFile throws, the handle is closed. If the "important" check throws, the handle is closed. If everything succeeds, the handle is still closed.

Limitations of try/finally

The try/finally pattern solves the single-resource case, but it has several limitations that become apparent as your code grows. Let’s look at each one.

The Multiple Resources Problem

Say we now want to read from an input file and write the processed content to an output file. That means two file handles open at the same time. The code would look something like this:

async function readAndWrite(
inputPath: string,
outputPath: string,
): Promise<void> {
const inputHandle = await open(inputPath, "r");
try {
const outputHandle = await open(outputPath, "w");
try {
const content = await inputHandle.readFile("utf-8");
const processed = content.toUpperCase();
await outputHandle.writeFile(processed, "utf-8");
} finally {
await outputHandle.close();
}
} finally {
await inputHandle.close();
}
}

Notice the nesting. Each resource adds another level of try/finally. The input handle gets its own try/finally, and the output handle gets its own try/finally nested inside. Our actual business logic (reading, processing and writing) is buried two levels deep.

Now imagine adding a third resource (maybe a log file). That is three levels of nesting. A fourth resource? Four levels. Each new resource pushes your actual business logic deeper into nested blocks, making the code harder to read and harder to maintain.

The Release Order Problem

Look at the readAndWrite function above. We close the output handle before we close the input handle. This is not accidental. Resources often depend on each other, and the order in which you release them matters.

Consider a scenario where you are streaming data from the input to the output in chunks rather than reading everything into memory at once. If you close the input handle first, the output handle might still need to read more data from the input to complete a write. That read would fail because the input handle is already closed.

The rule is: release resources in the reverse order you acquired them. If you acquired A first and B second, you should release B first and A second. Like a stack: last in, first out.

The nested try/finally pattern enforces this naturally. The inner finally (which closes the output handle) runs before the outer finally (which closes the input handle). But this ordering is a side effect of the nesting structure. If someone refactors the code, flattens the nesting, or reorders the blocks, the release order can silently break. There is nothing in the language that prevents this mistake.

The Composition Problem

try/finally does not compose. What if you want to write reusable functions that each manage their own resource, and then combine them?

You might try wrapping each resource in a helper function that accepts a callback:

async function withInputFile<T>(
path: string,
callback: (handle: FileHandle) => Promise<T>,
): Promise<T> {
const handle = await open(path, "r");
try {
return await callback(handle);
} finally {
await handle.close();
}
}
async function withOutputFile<T>(
path: string,
callback: (handle: FileHandle) => Promise<T>,
): Promise<T> {
const handle = await open(path, "w");
try {
return await callback(handle);
} finally {
await handle.close();
}
}

Each function is clean on its own. But when you need both resources at the same time, you are back to nesting:

await withInputFile("input.txt", async (input) => {
await withOutputFile("output.txt", async (output) => {
// Use both input and output...
// But we are nesting again!
});
});

The nesting is still there. There is no way to say “give me an input file AND an output file” in a flat, composable way using try/finally.

The Lifetime Problem

There is one more issue that try/finally cannot solve: shared lifetimes. What if two different parts of your application need the same resource, but you want it to be cleaned up only when both parts are done with it?

For example, imagine two functions that both need a database connection. You want to open one connection, share it between both functions, and close it only after both functions have finished. With try/finally, there is no mechanism for this. You would need to manually track how many consumers are still using the resource and only close it when the count reaches zero. This kind of manual reference counting is error-prone and adds complexity that has nothing to do with your actual business logic.

When we use Effect’s Scope to manage resources, we do not have to think about any of these problems. They are taken care of automatically. In the next chapter, we will see how Effect lets you manage multiple resources with zero nesting, automatic reverse-order cleanup and complete type safety, all without a single try/finally block.

Sign in to save progress

Stay in the loop

Get notified when new Effect Atom related content is published.