Get E-Book
File System

fs.promises and FileHandle Cleanup

Ishtmeet Singh @ishtms/February 22, 2026/29 min read
#nodejs#file-system#fs-promises#FileHandle#async-await

fs.promises changes how filesystem work reports completion. FileHandle changes who owns the open descriptor.

fs.promises and FileHandle

Path helpers such as readFile() open, operate, and close inside a single call. fs.promises.open() is different: it returns a FileHandle around an open descriptor, and that descriptor remains yours until something closes it. Most of the sharp edges in this chapter come from that ownership handoff: cleanup, stream lifecycles, current file position, durability calls, and parallel operations whose order is not guaranteed just because the lines appear in a certain order.

The callback-based fs API reports completion through a function you pass in. Node calls that function when the operation finishes, and you handle either the error or the result. The model works, but it gets awkward once several file operations depend on each other. Nested callbacks and repeated if (err) return callback(err) checks make control flow harder to read, especially when cleanup is involved.

The fs.promises namespace keeps the filesystem work asynchronous while changing the JavaScript shape of the code. Most asynchronous filesystem operations have a promise-based form. You import from node:fs/promises, await the result, and let errors move through try/catch.

The larger shift is FileHandle. fs.promises.open() returns an object that owns the descriptor and exposes descriptor-scoped methods. The file descriptor covered in subchapter 01 is still there, but application code usually manages it through the object instead of passing a raw integer around.

Path helper lifecycle contrasted with explicit FileHandle descriptor ownership.

Figure 1 — Path helpers keep the open-operate-close lifecycle inside one call. A FileHandle moves descriptor ownership into application code, so later operations and cleanup run against the same open file.

The fs.promises Namespace

You can import the promise API directly:

js
import { readFile, writeFile, open } from 'node:fs/promises';

Or, in a file that still needs both callback and promise APIs, you can reach it through node:fs:

js
import fs from 'node:fs';
const fsp = fs.promises;

Named imports are usually clearer in new code because they make the dependency visible and keep call sites short. The fs.promises property is useful during migration, where one module still contains callback-based calls alongside newer promise-based ones.

The examples in this chapter use ES module imports and assume either top-level await or an enclosing async function. Examples that use /tmp are POSIX-style temp paths; on Windows, use os.tmpdir() with path.join() or adapt the path to the target environment.

What's Available

The namespace mirrors almost every asynchronous fs function. Commonly used path-based helpers include:

  • readFile(path, options) - whole file into memory, returns Buffer or string
  • writeFile(path, data, options) - replaces file contents
  • appendFile(path, data, options) - appends to end
  • open(path, flags, mode) - opens file, returns a FileHandle
  • stat(path, options) and lstat(path, options) - metadata
  • readdir(path, options) - directory listing
  • mkdir(path, options) - create directory, supports { recursive: true }
  • rm(path, options) - remove files or directories, supports { recursive: true, force: true }
  • rename(oldPath, newPath) - rename or move
  • copyFile(src, dest, mode) - copy a file
  • cp(src, dest, options) - copy files or directories recursively, added in v16.7
  • unlink(path) - delete a file
  • symlink(target, path, type) - create symbolic link
  • link(existingPath, newPath) - create hard link
  • chmod(path, mode) - change permissions
  • chown(path, uid, gid) - change ownership
  • utimes(path, atime, mtime) - set timestamps
  • mkdtemp(prefix, options) - unique temporary directory
  • realpath(path, options) - resolve symlinks
  • access(path, mode) - check accessibility
  • truncate(path, len) - truncate file

These path-based helpers return promises that resolve with the result on success and reject with the error on failure. A few newer APIs, such as glob() and watch(), return async iterators instead. Once the operation is represented this way, you can await it sequentially, batch it with Promise.all(), chain it, or handle a whole group of failures through one try/catch.

Comparing the Two APIs

The callback fs.readFile and the promise-based readFile from node:fs/promises both use Node's filesystem machinery and run blocking filesystem work away from the event loop thread. At this level, the difference is not a different disk operation. It is the way completion comes back to JavaScript.

js
import { readFile } from 'node:fs/promises';

const data = await readFile('/tmp/config.json', 'utf8');
const config = JSON.parse(data);

The callback form expresses the same operation through an error-first callback:

js
import fs from 'node:fs';

fs.readFile('/tmp/config.json', 'utf8', (err, data) => {
  if (err) return handleError(err);
  const config = JSON.parse(data);
});

From the application's point of view, both versions read the same file. The promise version lets errors flow through try/catch and lets dependent operations stay linear. Treat callback-versus-promise performance as a measured decision; for ordinary file I/O, batching, streaming, and avoiding unnecessary work usually count more than the completion style.

That structural difference becomes clearer as soon as one operation depends on another:

js
const configText = await readFile('config.json', 'utf8');
const config = JSON.parse(configText);
const data = await readFile(config.dataPath, 'utf8');
processData(data);

The code reads from top to bottom. If any awaited operation or parse step throws, control moves to the nearest surrounding catch. There is no nesting and no repeated error branch at every step.

Error Handling Patterns

The basic promise error path is still a try/catch; the function doing the read does not need to decide every possible policy:

js
async function readJson(path) {
  const data = await readFile(path, 'utf8');
  return JSON.parse(data);
}

Add policy where the caller knows what a specific error means:

js
async function readOptionalJson(path) {
  try {
    return await readJson(path);
  } catch (err) {
    if (err.code === 'ENOENT') return null;
    throw err;
  }
}

Here, ENOENT means the file does not exist, and this caller has decided that a missing file can become null. Other failures still propagate. The error objects are the same kind you get from callbacks, including properties such as code and syscall; the handling has moved from if (err) branches into catch blocks.

One pattern looks tidy but causes trouble:

js
const data = await readFile(path, 'utf8').catch(() => null);

That catches every error: a missing file, permission denied, an I/O failure, or a bad path. If an inline .catch() is appropriate, make it specific about the errors you are willing to turn into a fallback. Otherwise, use try/catch and state the policy explicitly.

A different mistake is starting a promise and never observing its failure:

js
async function cleanup() {
  readFile('/tmp/data.txt'); // no await - errors become unhandled rejections
}

In current Node releases, the default unhandled-rejection mode is throw, so an unhandled rejection becomes an uncaught exception and normally terminates the process. A process-level unhandledRejection or uncaughtException handler can change what happens next, but relying on that is not error handling. Always await promise-based filesystem calls, or attach a .catch() when you intentionally start work in the background.

fs.promises.constants

The constants object on fs.promises mirrors fs.constants. Permission flags, file access modes, and copy flags are available from the same namespace as the promise helpers:

js
import { access, constants } from 'node:fs/promises';

await access('/tmp/data.txt', constants.R_OK | constants.W_OK);

The bitwise OR combines multiple checks. R_OK checks read permission, and W_OK checks write permission. If any check fails, the promise rejects. A missing file is reported with ENOENT; permission problems are often EACCES or EPERM; other filesystem errors can surface too.

You will most often use these constants with access(), copyFile(), and open(). For open(), string flags such as 'r' or 'w' are usually clearer than numeric constants, though they map to the same underlying values.

One detail is easy to miss with access(): it checks permissions at the moment you call it, but the path can change before you actually open the file. That creates a race between checking and using the result. For most operations, attempt the real operation inside try/catch and handle the error. Use access() as a precondition only when the check itself is the goal, such as reporting permissions in a file browser UI or validating configuration before a long-running process starts.

Directory Operations

Several directory helpers have options that change their behavior enough to deserve separate attention.

mkdir with { recursive: true } creates the entire path, including intermediate directories:

js
await mkdir('/tmp/a/b/c/d', { recursive: true });

If the path already exists, this form does not throw. Without recursive, creating /tmp/a/b/c/d fails when /tmp/a/b/c does not already exist. The return value is the first directory that was actually created, or undefined if none needed creating.

readdir with { withFileTypes: true } returns Dirent objects instead of strings:

js
const entries = await readdir('/tmp', { withFileTypes: true });
for (const entry of entries) {
  console.log(entry.name, entry.isFile(), entry.isDirectory());
}

Each Dirent has methods such as isFile(), isDirectory(), and isSymbolicLink(). On filesystems that expose directory entry types, this avoids a separate stat() call for each entry. When that type information is unavailable or not enough for your decision, follow up with stat() or lstat().

The { recursive: true } option on readdir, added in v20.1 and backported to v18.17, walks the entire directory tree:

js
const allFiles = await readdir('/project/src', { recursive: true });

It returns every file and subdirectory, with paths relative to the starting directory. The convenience has a cost: the whole listing is collected into memory at once, so large trees need more care.

rm with { recursive: true, force: true } is the promise equivalent of rm -rf:

js
await rm('/tmp/build-output', { recursive: true, force: true });

force: true suppresses the error when the path does not exist. Without it, removing a nonexistent path throws ENOENT.

The FileHandle Object

Calling fs.promises.open() gives you a FileHandle. Unlike fs.open() from the callback API, which passes a raw integer fd to its callback, promise-based open() returns an object that wraps the descriptor and provides methods on it.

js
import { open } from 'node:fs/promises';

const fh = await open('/tmp/data.txt', 'r');
try {
  console.log(fh.fd); // the raw integer, e.g. 21
} finally {
  await fh.close();
}

The fd property is available when you need the raw number, such as when passing it to a native addon or legacy callback-based code. In ordinary application code, though, you call methods on the FileHandle and let the object represent ownership.

Methods on FileHandle

A FileHandle groups several kinds of descriptor-scoped operations.

Reading and writing:

  • fh.read(buffer, offset, length, position) - low-level byte read into a buffer
  • fh.write(buffer, offset, length, position) - low-level byte write from a buffer
  • fh.readFile(options) - read the whole file from current position
  • fh.writeFile(data, options) - write data from the current position
  • fh.appendFile(data, options) - append data

Metadata and control:

  • fh.stat(options) - file metadata, such as size, timestamps, and permissions
  • fh.truncate(len) - shrink or extend file to len bytes
  • fh.chmod(mode) - change permissions
  • fh.chown(uid, gid) - change ownership
  • fh.utimes(atime, mtime) - update access/modification timestamps

Durability:

  • fh.sync() - flush data and metadata to disk, wrapping fsync
  • fh.datasync() - flush data and the metadata needed to retrieve it, wrapping fdatasync

Vectored I/O:

  • fh.readv(buffers, position) - scatter read into multiple buffers
  • fh.writev(buffers, position) - gather write from multiple buffers

Streams:

  • fh.createReadStream(options) - readable stream from this file
  • fh.createWriteStream(options) - writable stream to this file
  • fh.readLines(options) - readline interface for line-by-line iteration

Lifecycle:

  • fh.close() - close the underlying fd
  • fh[Symbol.asyncDispose]() - automatic close, used by await using

Most of these methods return promises. Stream methods return streams, and readLines() returns a readline interface that is itself async iterable.

Reading Bytes

The low-level fh.read() method reads directly into a buffer you provide:

js
const fh = await open('/tmp/data.bin', 'r');
try {
  const buf = Buffer.alloc(64);
  const { bytesRead, buffer } = await fh.read(buf, 0, 64, 0);
  console.log(bytesRead, buffer.subarray(0, bytesRead));
} finally {
  await fh.close();
}

The result contains bytesRead, which may be less than the requested length, and buffer, which is a reference to the same buffer you passed in. The position argument is the byte offset where the read starts. Pass null to read from the current file position instead.

There is also an object overload when you only care about a few options:

js
const fh = await open('/tmp/data.bin', 'r');
try {
  const result = await fh.read({ buffer: Buffer.alloc(64) });
  console.log(result.bytesRead, result.buffer);
} finally {
  await fh.close();
}

In that form, offset defaults to 0, length defaults to the buffer's length, and position defaults to null.

Writing Bytes

fh.write() mirrors the low-level read shape:

js
const fh = await open('/tmp/out.bin', 'w');
try {
  const data = Buffer.from('hello, file');
  await fh.write(data, 0, data.length, 0);
} finally {
  await fh.close();
}

Strings can be written directly:

js
const fh = await open('/tmp/out.txt', 'w');
try {
  await fh.write('some text', null, 'utf8');
} finally {
  await fh.close();
}

When the first argument is a string, the second argument is the position, or null for the current position, and the third is the encoding.

readFile and writeFile on FileHandle

Sometimes you open a file for one reason and then want a higher-level helper partway through. For example, you may need to inspect metadata before reading the whole file:

js
const fh = await open('package.json', 'r');
try {
  const stats = await fh.stat();
  if (stats.size > 10_000_000) throw new Error('too large');
  const content = await fh.readFile('utf8');
} finally {
  await fh.close();
}

This uses one open descriptor: open, check its size, then read through the same handle. Calling top-level stat() and then top-level readFile() would perform two independent path-based operations. The raw syscall count is not the main point; consistency is. The path could be replaced between those two calls.

fh.writeFile() writes from the handle's current position. If you opened the file with 'w', the open itself truncated the file first, so the result looks like a replacement:

js
const fh = await open('/tmp/state.json', 'w');
try {
  await fh.writeFile(JSON.stringify({ count: 42 }));
} finally {
  await fh.close();
}

The current position still controls the result. If earlier fh.read() or fh.write() calls have moved it, a later fh.readFile() or fh.writeFile() continues from there rather than automatically starting at byte zero.

Lines and Streams from FileHandle

fh.readLines() returns a readline interface that is async iterable. The FileHandle itself is not the line iterator:

js
const fh = await open('/tmp/log.txt', 'r');
for await (const line of fh.readLines()) {
  process.stdout.write(line + '\n');
}

By default, readLines() closes the FileHandle when the interface closes. If you pass { autoClose: false }, the surrounding code still owns the handle and must close it.

For chunked reads, create a stream from the handle:

js
const fh = await open('/tmp/data.csv', 'r');
try {
  const stream = fh.createReadStream({ encoding: 'utf8', autoClose: false });
  for await (const chunk of stream) {
    processChunk(chunk);
  }
} finally {
  await fh.close();
}

The default for filehandle.createReadStream() and filehandle.createWriteStream() is autoClose: true, which closes the FileHandle when the stream closes. Set autoClose: false only when the surrounding code owns cleanup and waits for the stream lifecycle to finish before closing the handle.

stat, truncate, and datasync

Some of the less common methods count because they operate on the file you already opened.

fh.stat() returns the same Stats object as the top-level stat(), but it reads metadata from the open descriptor. That is useful when a decision about reads or writes must apply to this specific open file rather than whatever currently lives at the same path.

fh.truncate(len) sets the file's size. If len is shorter than the current size, the file shrinks and the trailing bytes are removed. If len is longer, the file grows and the new bytes are filled with zero bytes, or a sparse hole on filesystems that support it. This shows up when rewriting a file with shorter content; without truncation, the tail of the old content remains.

js
const fh = await open('/tmp/data.txt', 'r+');
try {
  await fh.writeFile('short');
  await fh.truncate(5);
} finally {
  await fh.close();
}

fh.datasync() and fh.sync() both force buffered data to disk. The difference is metadata. sync() flushes data and file metadata, while datasync() may skip metadata that is not needed for later data retrieval. On Linux, for example, fdatasync() can skip timestamp metadata, but a file-size change still requires metadata to reach disk. If you care about complete metadata persistence, use sync(). If you care about data integrity and can tolerate looser metadata guarantees, datasync() may reduce metadata work; measure before treating it as a performance optimization.

The close() Obligation

When open() returns a FileHandle, you own that file descriptor until you call close(). If you forget, the descriptor leaks. Enough leaks eventually hit the per-process fd limit, which triggers EMFILE errors, covered in subchapter 01, for unrelated work such as file opens, socket connections, and pipe creation.

The basic ownership pattern is:

js
const fh = await open(path, 'r');
try {
  const data = await fh.readFile('utf8');
  return JSON.parse(data);
} finally {
  await fh.close();
}

The finally block runs whether the read succeeds, parsing succeeds, or either step throws. A catch block can sit between try and finally if this function has an error policy, but cleanup belongs in finally.

If open() itself fails, no FileHandle exists yet. The try/finally body is never entered, there is nothing to close, and the open error propagates normally.

Multiple handles make the same rule more visible:

js
const src = await open(srcPath, 'r');
try {
  const dest = await open(destPath, 'w');
  try {
    await dest.writeFile(await src.readFile());
  } finally {
    await dest.close();
  }
} finally {
  await src.close();
}

This is correct, but it is noisy. Each owned resource needs its own cleanup path, and the nesting grows quickly as handles are added.

What Happens When You Don't Close

Node tracks unclosed FileHandle objects. If one becomes unreachable while still open, garbage collection can trigger an attempted close of the underlying fd and may print a warning similar to this:

text
(node:12345) Warning: Closing file descriptor 21 on garbage collection

The fd number in the warning can help during debugging, but the mechanism is only a fallback. Garbage collection timing is unpredictable. V8 might not collect for seconds or minutes, depending on memory pressure, and during that time the fd remains open and counts against the process limit. An open FileHandle by itself does not keep the event loop alive like a server or timer; the production risk is descriptor pressure while the process is still doing work.

await using

FileHandle implements Symbol.asyncDispose, so runtimes that support explicit resource management can use await using. In Node.js, filehandle[Symbol.asyncDispose]() is stable starting in v24.2; earlier releases exposed it as experimental. Check the runtime baseline and parser/tooling support before using this syntax in shared code:

js
async function readConfig(path) {
  await using fh = await open(path, 'r');
  return JSON.parse(await fh.readFile('utf8'));
}

When the function scope exits, whether by returning normally or throwing, the runtime calls fh[Symbol.asyncDispose](), which calls fh.close(). After successful initialization, await using schedules the handle for disposal when the block exits.

With multiple handles, disposal runs in reverse declaration order:

js
async function copyWithHandles(src, dest) {
  await using srcFh = await open(src, 'r');
  await using destFh = await open(dest, 'w');
  await destFh.writeFile(await srcFh.readFile());
}

Here, destFh closes first and srcFh closes second. That matches the usual cleanup-stack convention and gives the same order as the nested try/finally version with less boilerplate.

Two FileHandle resources leaving a scope in reverse disposal order.

Figure 2 — await using treats owned handles as a cleanup stack. The last handle registered in the scope is disposed before earlier handles.

How Symbol.asyncDispose Works

When you write await using x = expr, the runtime performs a small resource-management sequence:

  1. Evaluates expr and assigns the result to x.
  2. Checks that x has a [Symbol.asyncDispose] method, or [Symbol.dispose] for synchronous using.
  3. Registers x in a disposal stack for the current block scope.
  4. When the block exits, iterates the stack in reverse order and calls await x[Symbol.asyncDispose]() for each resource.

Conceptually, the async disposer on FileHandle behaves like this:

js
[Symbol.asyncDispose]() {
  return this.close();
}

It just calls close(). The cleanup behavior comes from the language syntax: await using arranges for that method to run at scope exit. If disposal fails while the block is already throwing, the error is wrapped in a SuppressedError, preserving both the original error and the disposal error.

When to Use await using vs try/finally

Use await using when a simple ownership scope is all you need: open a file, do work, and guarantee cleanup. It is especially nice when several resources need predictable reverse-order disposal.

Use try/finally when the cleanup path needs custom behavior, such as logging, metrics, conditional cleanup, or special handling for close errors. It is also the safer form when the code must run on older Node versions or pass through tooling that does not yet accept explicit resource management syntax.

For code that explicitly targets Node v24.2+ and compatible tooling, await using is a strong default for simple FileHandle ownership. Keep try/finally when compatibility, close-error policy, or extra cleanup work is involved.

Convenience Functions vs FileHandle

The promise filesystem API has two tiers. Convenience functions such as readFile, writeFile, stat, and mkdir operate on paths and manage any internal resources needed for that single operation. Content helpers such as readFile() and writeFile() open and close internally; metadata and directory helpers may use direct path-based syscalls instead.

js
const data = await readFile('/tmp/config.json', 'utf8');

FileHandle operations begin with open(). You get a handle, perform descriptor-scoped work, and close the handle when the ownership scope ends. That is more code, but it gives you more control.

The convenience helpers fit one-shot operations: reading a config file, writing a result, checking a path, or creating a directory. The open/close lifecycle is handled by the helper when one is needed.

Use FileHandle when several operations need to refer to the same open file. You might read a header, seek to a position, and write; check metadata before reading; or hold a file open across async handoffs while processing batches or appending over time.

The consistency angle is often more important than the code shape. With convenience functions, this is two independent path-based operations:

js
const stats = await stat(path);
const data = await readFile(path, 'utf8');

Between those calls, another process could replace the path. With a FileHandle, both operations refer to the same open file:

js
await using fh = await open(path, 'r');
const stats = await fh.stat();
const data = await fh.readFile('utf8');

That means one open(), one descriptor, and one close. For a single file, the benefit is mostly correctness and clarity. For large batch jobs, benchmark the whole workflow before claiming a performance win.

Parallel File Operations

Independent file operations can overlap. Promise.all() starts the promise-returning calls before waiting for the group:

js
const [configText, schemaText, dataText] = await Promise.all([
  readFile('config.json', 'utf8'),
  readFile('schema.json', 'utf8'),
  readFile('data.json', 'utf8'),
]);

All three reads are initiated before the await. Node schedules the underlying filesystem work through libuv's worker pool, and completion order is independent of the array order. If one promise rejects, Promise.all() rejects with that error and that await does not return the other fulfilled values.

When each result should be inspected even if some operations fail, use Promise.allSettled():

js
const results = await Promise.allSettled([
  readFile('a.json', 'utf8'),
  readFile('b.json', 'utf8'),
  readFile('maybe-missing.json', 'utf8'),
]);

Each element in results has a status of 'fulfilled' or 'rejected', with value or reason respectively. That lets you process the successes and handle the failures individually.

Several filesystem requests entering a queue, running through four worker slots, and completing out of order.

Figure 3 — Promise-based filesystem work can overlap, but requests still pass through finite worker-pool capacity and storage throughput. Completion order is independent of the array order.

When Parallel Hurts

Parallel file I/O is not automatically faster. The libuv worker pool defaults to four threads, so a flood of filesystem requests queues behind a small pool. A convenience operation such as readFile() may involve several internal filesystem requests, so do not model one whole readFile() call as one worker from start to finish. If the files live on the same physical disk, parallel reads can cause head movement on spinning drives or exceed I/O controller throughput on SSDs.

For large batches, a chunked loop gives you a simple first limit on concurrency:

js
async function readBatch(paths, concurrency = 8) {
  const results = [];
  for (let i = 0; i < paths.length; i += concurrency) {
    const batch = paths.slice(i, i + concurrency);
    const data = await Promise.all(batch.map(p => readFile(p, 'utf8')));
    results.push(...data);
  }
  return results;
}

This processes up to concurrency files at a time, waits for that chunk to finish, and then moves to the next chunk. It is not a dynamic worker pool, but it is often enough to prevent thread-pool starvation and keep I/O pressure manageable.

Mixing Sequential and Parallel

Real workflows usually combine dependency-driven sequential work with independent parallel work. A config file may decide which data files to read; those data files can be read together; a summary write waits for all of them:

js
const config = JSON.parse(await readFile('config.json', 'utf8'));
const datasets = await Promise.all(
  config.files.map(f => readFile(f, 'utf8'))
);
const summary = buildSummary(datasets);
await writeFile('summary.json', JSON.stringify(summary));

The structure follows the data dependencies: sequential where one result is needed to choose the next step, parallel where operations are independent. The thread pool handles the overlap, while the code still reads from top to bottom.

One write-specific constraint is easy to miss: do not run uncoordinated writeFile() calls against the same path. Node does not synchronize concurrent modifications for you, and writeFile() may perform multiple internal writes. Serialize same-path writes, route them through one stream or queue, or use carefully positioned writes where the ordering contract is explicit. Parallel writes to different files are much easier to reason about.

Migrating from Callbacks to Promises

For code already using callback-based fs, the conversion pattern is mechanical.

Before:

js
fs.readFile(path, 'utf8', (err, data) => {
  if (err) return handleError(err);
  doSomething(data);
});

After:

js
try {
  const data = await readFile(path, 'utf8');
  doSomething(data);
} catch (err) {
  handleError(err);
}

Callback result parameters become await assignments. Error-first if (err) branches become catch blocks. Nested callbacks flatten into sequential await lines.

Wrapping Legacy Code

For third-party libraries or your own functions that still use callbacks, util.promisify converts callback-style functions into promise-returning ones:

js
import { promisify } from 'node:util';
import { stat } from 'node:fs';

const statAsync = promisify(stat);
const info = await statAsync('/tmp/data.txt');

You do not need this for fs itself because node:fs/promises already exists. It is still useful for older modules or custom callback-based APIs.

The opposite direction is also possible when a callback-based caller needs to invoke promise-based code:

js
function legacyReadConfig(path, callback) {
  readFile(path, 'utf8')
    .then(data => callback(null, JSON.parse(data)))
    .catch(err => callback(err));
}

Both directions can coexist during migration. New code can use promises while older surfaces keep their callback contract until you are ready to change them.

Migration Pitfalls

A few mistakes tend to appear during migration.

Forgetting to await. An async function that calls writeFile() without await returns immediately. The write continues in the background, and a later line that depends on the file may fail intermittently.

Double error handling. Wrapping await in try/catch and also chaining .catch() around the same call is usually redundant. Pick one style at a call site so the path for errors stays obvious.

Unhandled rejections from fire-and-forget. Calling an async function without awaiting it and without .catch() leaves a rejection with nowhere to go. Current Node defaults treat unhandled rejections as uncaught exceptions unless process-level handlers change that behavior. If you intentionally fire and forget, attach a .catch():

js
void writeFile('/tmp/log.txt', logData).catch(console.error);

Mixing sync and promise in the same function. Code sometimes calls fs.existsSync() before await readFile(). It works, but the synchronous check blocks the event loop. In an async function, keep the path asynchronous:

js
try {
  await access(path);
} catch (err) {
  if (err.code === 'ENOENT') {
    // file does not exist
  } else {
    throw err;
  }
}

Often, the better pattern is to attempt the real operation and handle the error rather than checking first.

Error code differences. There are no special promise-only filesystem errors. Error objects from fs.promises have the same properties as callback errors: code values such as ENOENT, EACCES, and EISDIR; syscall values such as open, read, and stat; and path information where available. Existing error policies usually transfer directly from callback branches into catch blocks.

How fs.promises Uses the Thread Pool

The production-level contract is simple: promise-based filesystem APIs use Node's thread pool to perform filesystem work off the event loop thread. The callback APIs do the same. The difference your JavaScript code writes against is the completion model: callback invocation versus promise settlement.

Do not treat every convenience helper as a single libuv request. readFile() and writeFile() may perform multiple reads or writes internally, and path-based helpers such as stat() can use path metadata syscalls without opening the file. When you need the exact number of syscalls, measure the concrete operation on the target platform.

Conceptually, a path-based readFile() proceeds like this:

text
fsPromises.readFile(path)
  -> validate path and options
  -> open the path for reading
  -> read until the requested data is collected or an error occurs
  -> close the descriptor it opened
  -> fulfill or reject the promise

That sequence is deliberately conceptual, not a copy of Node's private implementation. Internal file names, request wrapper names, and C++ classes are not part of the public API and can change across Node versions.

FileHandle methods operate against an already-open descriptor. Promise-returning methods settle when their operation completes. Stream and readline methods return objects with their own lifecycles, and their autoClose defaults decide whether the stream owns handle closure or the surrounding code does.

After fh.close() completes, fh.fd is -1, and later operations fail because the handle is closed. If a FileHandle is abandoned without being closed, current Node releases can emit a garbage-collection warning and attempt cleanup, but that is a diagnostic fallback. Treat it as a bug, not as a resource-management strategy.

Combining FileHandle with Streams

FileHandle can create streams tied to its fd:

js
await using fh = await open('/tmp/large-file.csv', 'r');
const stream = fh.createReadStream({
  encoding: 'utf8',
  autoClose: false,
});

The stream reads from the file handle's descriptor. By default, FileHandle streams own closure: autoClose: true closes the FileHandle when the stream closes. If the surrounding await using or try/finally owns cleanup instead, pass autoClose: false, wait for the stream to finish, and make sure the stream has released the handle before the scope closes.

filehandle.close() waits for pending operations on the handle. A stream created from the handle is another source of work against that descriptor, so do not close the FileHandle while the stream is still active.

A safe read pattern is to let pipeline() define the stream lifetime:

js
import { pipeline } from 'node:stream/promises';

await using fh = await open('input.csv', 'r');
const readable = fh.createReadStream({ autoClose: false });
await pipeline(readable, transformStream, outputStream);

await pipeline(...) resolves when all data has flowed through or rejects if the pipeline fails. After that, the await using scope can close the handle in the right order.

For writable streams, the safest default is to let the stream own closure:

js
import { finished } from 'node:stream/promises';

const fh = await open('output.log', 'a');
const writable = fh.createWriteStream();
writable.write('entry 1\n');
writable.write('entry 2\n');
writable.end();
await finished(writable);

Calling writable.end() signals that no more data is coming. finished(writable) waits until the stream has completed or failed. Because autoClose stays at its default, the stream closes the FileHandle; do not use fh after the stream finishes.

If the FileHandle scope must own cleanup, prefer FileHandle write methods:

js
await using fh = await open('output.log', 'a');
await fh.appendFile('entry 1\n');
await fh.appendFile('entry 2\n');

Avoid combining a FileHandle write stream with autoClose: false and then relying on await using to close the handle after finished(writable). In Node v24.15, finished(writable) can resolve while the stream still holds the FileHandle open; filehandle.close() then waits for that pending stream state.

When to Use Which API

There are three filesystem styles on the table: synchronous fs.*Sync, callback-based fs.*, and promise-based fs.promises.*.

Sync is appropriate for startup code, CLI tools, and build scripts where blocking the event loop is acceptable. It is simple, but it blocks the thread.

Callbacks fit legacy code that is already callback-based, libraries that expect callback contracts, and performance-sensitive hot paths only when measurement shows the callback form makes a difference.

Promises fit new application code, server handlers, middleware, batch jobs, and code already structured around async/await.

The ecosystem has mostly moved toward promises and async/await. HTTP frameworks, database drivers, and queue clients commonly use promises. File operations are easier to compose when they match that surrounding style. Mixing callbacks into otherwise promise-based code forces readers to track two completion models and two error-handling paths.

For performance, use promises unless profiler data points elsewhere. For normal file I/O, structure usually has more impact than the wrapper: avoid duplicate work, stream large files, and batch independent operations with controlled concurrency.

Current Node.js documentation and examples increasingly assume async/await. Newer filesystem features often have promise, callback, and synchronous forms, but promise examples tend to compose better with contemporary application code.

The glob() function is still new enough that some codebases reach for third-party packages such as fast-glob or globby. It was added in Node v22 and marked stable in Node v24.0 and v22.17:

js
import { glob } from 'node:fs/promises';

for await (const tsFile of glob('**/*.ts', { cwd: '/project/src' })) {
  console.log(tsFile);
}

glob() returns an async iterable of matching paths. Iterate with for await...of, or collect results into an array with Array.fromAsync() when your Node baseline supports it. For tooling scripts, built-in globbing removes one common dependency when the runtime baseline supports it.

The through-line is resource ownership. Path helpers are best when one call owns the whole operation. FileHandle is best when you need one open descriptor across several operations, but then closure must be explicit: try/finally, await using, or a stream with well-understood autoClose behavior.