The File System

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-03, and every example below was run on all three lines unless the text says otherwise. The node:fs module is Stability 2 (Stable) as a whole, and that covers the three API styles, FileHandle, fs.Stats, fs.openAsBlob() and filehandle.readableWebStream(). Three corners are not stable: fs.Utf8Stream and the filehandle.pull(), filehandle.pullSync() and filehandle.writer() methods are Stability 1 (Experimental), and fs.exists() is Stability 0 (Deprecated). Everything here is written for the Bookshelf application that grows across this section (see Introduction & Architecture): a catalog of books kept in JSON, an export in CSV, cover images, and a log. The page covers files; directories, glob patterns and file watching have their own page, Directories, Glob & Watch, and chunked reading and writing is the subject of Streams. The path helpers (join, resolve, dirname) are covered in OS, Path & URL.

Observations about operating systems come from the documentation unless the text says observed; the observed results were taken on macOS (an APFS volume, plus two small disk images created for the experiments: a case-sensitive APFS volume and a FAT32 volume). Linux and Windows behaviour is quoted from the documentation, never assumed.

The stability and version map

Table 1. APIs and settings covered on this page
API or setting Stability (Node.js 26 docs) Version notes

node:fs (callback and synchronous APIs), node:fs/promises

2 Stable

require('fs/promises') exists since v14.0.0; the promise API stopped being experimental in v11.14.0 and v10.17.0.

FileHandle and await using (filehandle[Symbol.asyncDispose]())

Stable

No longer experimental since v24.2.0. The await using syntax itself needs a V8 that parses it: it is a SyntaxError on Node.js 22 (observed).

fsPromises.readFile() and fs.readFile() option buffer

Stable

Added in v26.4.0 and v24.19.0. Not in the Node.js 22 documentation; observed to be silently ignored there.

fs.stat() option throwIfNoEntry

Stable

Synchronous form for a long time; the promise and callback forms gained it in v25.7.0 and v24.15.0 (not on 22).

fs.Stats properties atimeInstant, mtimeInstant, ctimeInstant, birthtimeInstant

Stable

Added in v26.2.0 (Temporal.Instant values). Absent on 24 and 22 (observed).

fs.openAsBlob(), filehandle.readableWebStream()

Stable

Marked stable in v24.0.0 and v22.17.0. fs.openAsBlobSync() was added in v26.10.0.

fs.Utf8Stream

1 Experimental

Added in v24.6.0; missing on Node.js 22 (observed).

filehandle.pull(), filehandle.pullSync(), filehandle.writer()

1 Experimental

Added in v25.9.0, tied to the experimental stream/iter module.

fs.exists()

0 Deprecated

Documentation-only deprecation DEP0034 (since v1.0.0). fs.existsSync() is not deprecated.

Closing a FileHandle on garbage collection

End-of-Life (DEP0137)

Runtime warning from v14.0.0; an error since v25.0.0 (observed: it crashes the process on Node.js 26).

fs.F_OK, fs.R_OK, fs.W_OK, fs.X_OK; fs.rmdir(path, { recursive: true }); fs.truncate() with a file descriptor

End-of-Life (DEP0176, DEP0147, DEP0081)

The first two were removed in v25.0.0, the last in v24.0.0.

The permission model (--permission, --allow-fs-read, --allow-fs-write)

2 Stable

Added in v20.0.0; the network flag --allow-net is a separate, less mature feature.

Table 2. Where each of the seven Learn articles on manipulating files is covered
Learn article Section of this page

Node.js File Paths

Naming a file: strings, URLs and Buffers, with the helpers in OS, Path & URL

Reading files with Node.js

Reading whole files

Writing files with Node.js

Writing and appending

Working with file descriptors in Node.js

File descriptors and FileHandle

Node.js file stats

Stats: what is this file?

Working with folders in Node.js

Folders at a glance and Directories, Glob & Watch

How to work with different filesystems

Different file systems behave differently

How the file system API works underneath

The node:fs module is, in the words of its documentation, modeled on standard POSIX functions: open, read, stat, rename and friends, with the same names and, for system errors, the same error codes. Three layers are involved. The JavaScript functions you call validate the arguments, translate paths and options, and build the result (a Buffer, a string, an fs.Stats object). They call C++ bindings inside Node.js, which turn each call into a request for libuv. libuv then talks to the operating system.

The important design fact comes from libuv: unlike network sockets, files have no portable, platform-neutral way to be waited on, so libuv runs blocking file system calls on its thread pool. The Node.js documentation states the consequence: all callback and promise-based file system APIs, with the exception of fs.FSWatcher, use libuv’s thread pool, which can have surprising performance effects when the pool (four threads by default, UV_THREADPOOL_SIZE) is busy. The synchronous functions skip the hand-off and block the calling thread until the system call returns. The Event Loop shows the full journey of an fs.readFile() call through these layers, and its measurement of a stat() call queued behind CPU-bound pool tasks applies to every call on this page.

Table 3. Where the work of each API style runs
Style Who does the system call What the event loop does meanwhile

Promises (node:fs/promises)

A libuv thread pool thread

Keeps running; your code resumes after the await

Callbacks (node:fs)

A libuv thread pool thread

Keeps running; the callback is queued when the work completes

Synchronous (readFileSync() and the other Sync functions)

The calling thread itself

Nothing: the whole process is frozen until the call returns

Three API styles

Every operation exists in three forms, reachable from both ES modules and CommonJS. The promise API lives in its own module, node:fs/promises (also available as the promises property of node:fs; the two are the same object, observed). The callback and synchronous forms live in node:fs, and the synchronous ones carry a Sync suffix. The promise API additionally has FileHandle objects; the callback and synchronous APIs use numeric file descriptors instead (see File descriptors and FileHandle).

The same read in each style
// One operation, three styles
import { readFile as readFileCb, readFileSync } from 'node:fs';
import { readFile } from 'node:fs/promises';

const catalog = new URL('./catalog.json', import.meta.url);

// 1. Promises: the default choice in application code
const viaPromise = JSON.parse(await readFile(catalog, 'utf8'));

// 2. Callback: error first, runs later
readFileCb(catalog, 'utf8', (err, text) => {
  if (err) throw err;
  console.log('callback  :', JSON.parse(text).length, 'books');
});

// 3. Sync: blocks the thread until the data is there
const viaSync = JSON.parse(readFileSync(catalog, 'utf8'));

console.log('promise   :', viaPromise.length, 'books');
console.log('sync      :', viaSync.length, 'books');
Output on Node.js 26, 24 and 22
promise   : 3 books
sync      : 3 books
callback  : 3 books

The order of the lines is the lesson. The promise and the synchronous read are finished by the time the first two lines print; the callback runs later, after the main code has returned to the event loop, which is why it prints last even though it was started second. A callback is never called synchronously by the file system API, so code after the call always runs first.

Table 4. Choosing a style
Style Result and errors Event loop Use it for

Promises

Returns a promise; errors reject, with try/catch around await

Free to continue

Application code: servers, workers, anything long-running. The default choice.

Callback

Last argument is (err, result); the callback is mandatory

Free to continue

Older code and APIs that predate promises. Convert with util.promisify() only when there is no promise form.

Synchronous

Returns the result; errors are thrown

Blocked until done

Start-up, short-lived scripts and the other cases below.

Two history notes matter when reading old code. Callback-less asynchronous calls (fs.readFile(path)) were deprecated in v7.0.0 and throw ERR_INVALID_ARG_TYPE today (observed on Node.js 26), and the error passed to a callback may be an AggregateError when several errors occur (since v16.0.0). The promise API is also where FileHandle objects live, and it accepts a FileHandle in place of a path in some functions (readFile(), writeFile()).

When synchronous calls are acceptable

A synchronous call blocks the one JavaScript thread, so nothing else (timers, other requests, signal handlers) runs until it returns. The Learn article on blocking versus non-blocking names the synchronous standard-library methods that use libuv as the most common blocking operations: further JavaScript must wait until they finish. The measurement below reads a 200 MiB file twice, once in each style, while a timer asks to fire every millisecond.

A synchronous read freezes the timer; the promise read does not
// A synchronous read freezes the event loop; the promise version lets timers keep running
import { readFileSync, writeFileSync, rmSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const big = join(tmpdir(), 'bookshelf-big.bin');
writeFileSync(big, Buffer.alloc(200 * 1024 * 1024, 1));      // 200 MiB of test data

let ticks = 0;
const timer = setInterval(() => ticks++, 1);                  // wants to run every millisecond

try {
  let started = performance.now();
  readFileSync(big);
  console.log(`readFileSync: ${(performance.now() - started).toFixed(0)} ms, timer ticks: ${ticks}`);

  ticks = 0;
  started = performance.now();
  await readFile(big);
  console.log(`readFile    : ${(performance.now() - started).toFixed(0)} ms, timer ticks: ${ticks}`);
} finally {
  clearInterval(timer);
  rmSync(big);
}
Typical output on Node.js 26 (24 and 22 are within a few milliseconds of it)
readFileSync: 16 ms, timer ticks: 0
readFile    : 16 ms, timer ticks: 17

The synchronous read took about as long as the asynchronous one but delivered no timer ticks at all while it ran: for that whole time the process could not have answered a request. Reading a small file is far cheaper, but the principle scales with traffic, as Don’t Block the Event Loop explains.

Table 5. Where synchronous file calls are acceptable, and where they are not
Acceptable Not acceptable

Start-up work that finishes before the server begins to listen: reading a configuration file, TLS certificates, templates, a seed catalog

Anything that runs per request, per message or per event after start-up

A command-line tool or build script that does one thing and exits (see Building CLI Tools)

Large files, files on network mounts or removable media, where latency is unpredictable

An 'exit' event listener on process, where the documentation says only synchronous work is possible

Loops over many files inside a long-running process

A worker thread dedicated to the job, where blocking stalls only that worker

Code that other code awaits concurrently, such as a library function called from a server

Tests and one-off scripts

Mixing with asynchronous operations on the same file (see the ordering example below)

The usual shape of a service is therefore synchronous at start-up, asynchronous afterwards:

Synchronous at start-up, promises inside the request handler
// Sync at start-up (once, before serving), promises inside request handlers
import { readFileSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { createServer } from 'node:http';

// Acceptable: runs once, nothing else is waiting yet
const catalog = JSON.parse(readFileSync(new URL('./catalog.json', import.meta.url), 'utf8'));

const server = createServer(async (req, res) => {
  try {
    if (req.url === '/books') {
      res.setHeader('content-type', 'application/json');
      res.end(JSON.stringify(catalog));                       // in memory: no I/O at all
    } else if (req.url === '/about') {
      // Per-request file access: asynchronous, so other requests keep being served
      res.end(await readFile(new URL('./about.txt', import.meta.url)));
    } else {
      res.statusCode = 404;
      res.end('not found');
    }
  } catch (err) {
    res.statusCode = err.code === 'ENOENT' ? 404 : 500;
    res.end(err.code === 'ENOENT' ? 'not found' : 'internal error');
  }
});

server.listen({ port: 0, host: '127.0.0.1' }, async () => {
  const { port } = server.address();
  for (const path of ['/books', '/about', '/other']) {
    const response = await fetch(`http://127.0.0.1:${port}${path}`);
    console.log(path.padEnd(7), response.status, (await response.text()).slice(0, 30).replace(/\s+/g, ' '));
  }
  server.close();
});
Output on Node.js 26, 24 and 22
/books  200 [{"isbn":"978-0-00-000001-1","
/about  200 About the Bookshelf demo.
/other  404 not found

There is no ordering between independent calls

Because each callback or promise call is handed to a different pool thread, two calls started back to back may finish in any order. The documentation gives a rename followed by a stat of the new name as the canonical mistake. The experiment starts the stat at the same time as the rename, 200 times:

Starting two dependent calls together is a race
// No ordering between independent fs calls: they run on different thread pool threads
import { rename, stat, writeFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const dir = await mkdtemp(join(tmpdir(), 'bookshelf-'));
let failed = 0;
const runs = 200;
try {
  for (let i = 0; i < runs; i++) {
    const from = join(dir, `a${i}`);
    const to = join(dir, `b${i}`);
    await writeFile(from, 'x');
    // Both calls are started immediately: stat may reach the disk before rename does
    const [, statted] = await Promise.allSettled([rename(from, to), stat(to)]);
    if (statted.status === 'rejected') failed++;
  }
  console.log(`stat() failed with ENOENT in ${failed} of ${runs} runs`);

  // Correct: await the first operation, then start the second
  const from = join(dir, 'c');
  await writeFile(from, 'x');
  await rename(from, join(dir, 'd'));
  console.log('ordered:', (await stat(join(dir, 'd'))).size, 'byte');
} finally {
  await rm(dir, { recursive: true, force: true });
}
Output on Node.js 26 (198 to 200 of 200 on 24 and 22; observed, it varies from run to run)
stat() failed with ENOENT in 198 of 200 runs
ordered: 1 byte

The fix is the one the documentation states: await the first operation before starting the second (or, with callbacks, start the second inside the first’s callback). The same rule applies to mixed styles: an asynchronous readFile() followed by a synchronous unlinkSync() of the same file will usually delete the file before the read starts.

Naming a file: strings, URLs and Buffers

Most fs functions accept a path in one of three forms, as the Learn article and the API reference describe.

Table 6. The three path forms
Form How it is interpreted Watch out for

String

UTF-8 text naming an absolute or relative file. A relative path is resolved against process.cwd(), the directory where the process was started, not against the file that contains the call.

Backslashes on Windows; the working directory changing between runs.

file: URL (a URL object)

Always absolute. new URL('./catalog.json', import.meta.url) names a file next to the current module, wherever Node.js was started.

Encoded separators (%2F) are rejected on every platform; on Windows a host name becomes a UNC path and a URL with no host needs a drive letter; on other platforms a host name is an error.

Buffer

Raw bytes; useful on POSIX systems where a path is an opaque byte sequence that may mix encodings.

Rarely needed; every string path is converted from UTF-8.

On Windows the documentation adds three details: each drive has its own working directory (fs.readdirSync('C:') and fs.readdirSync('C:\\') can differ), the characters < > : " / \ | ? * are reserved, and under NTFS a colon in a name makes Node.js open an alternate data stream rather than a file named with a colon.

Relative paths depend on where node starts; module-anchored paths do not
// Three ways to name the same file
import { readFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { fileURLToPath } from 'node:url';

// Relative paths are resolved against the working directory (where node was started)...
console.log('relative  :', await readFile('catalog.json').then(() => 'found', (err) => err.code));

// ...so anchor files to the module instead
const viaJoin = join(import.meta.dirname, 'catalog.json');       // a string path
const viaUrl = new URL('./catalog.json', import.meta.url);       // a file: URL
console.log('url       :', viaUrl.protocol, basename(fileURLToPath(viaUrl)));

const [fromPath, fromUrl, fromBuffer] = await Promise.all([
  readFile(viaJoin),
  readFile(viaUrl),
  readFile(Buffer.from(viaJoin)),                                 // a Buffer path: raw bytes on POSIX
]);
console.log('same bytes:', fromPath.equals(fromUrl) && fromPath.equals(fromBuffer), fromPath.length, 'bytes');
Output on Node.js 26, 24 and 22, started from another directory (first three lines) and from the project directory (last three)
relative  : ENOENT
url       : file: catalog.json
same bytes: true 247 bytes
relative  : found
url       : file: catalog.json
same bytes: true 247 bytes

import.meta.dirname and import.meta.filename are the ES-module replacement for dirname and filename; their CommonJS equivalents are covered in ECMAScript Modules and CommonJS Modules. The node:path functions the Learn article lists (dirname, basename, extname, join, resolve, normalize) only compute strings: as the article says, neither resolve() nor normalize() checks that the path exists. That has a security consequence developed in Links: symbolic, hard, and why paths are not trustworthy.

Reading whole files

readFile() (and readFileSync()) is the simplest way to get a file’s content: it opens the file, reads all of it into memory and closes it. Without an encoding it returns a Buffer (see Buffers & Binary Data); with one, a string. A string as the options argument is shorthand for the encoding.

Table 7. Options of readFile()
Option Meaning Default and notes

encoding

Decode the bytes into a string ('utf8', 'latin1', 'base64', and the other Buffer encodings).

null: return a Buffer.

flag

How to open the file; see Flags.

'r'

signal

An AbortSignal that cancels the read. The promise rejects (or the callback receives) an AbortError.

Aborting stops Node.js’s internal buffering, not an individual operating-system request already in flight.

buffer

A Buffer, typed array or DataView to read into, or a function that receives the file size and returns one. The result is a Buffer view over it holding only the bytes read; a buffer that is too small is an error.

Added in v26.4.0 and v24.19.0; absent on Node.js 22.

readFile() options in action
// readFile options: encoding, signal, a caller-supplied buffer (Node.js 26.4+), and a directory
import { readFile } from 'node:fs/promises';
import { Buffer } from 'node:buffer';

const file = new URL('./catalog.json', import.meta.url);

const bytes = await readFile(file);               // no encoding: a Buffer
const text = await readFile(file, 'utf8');        // a string (same as { encoding: 'utf8' })
console.log(Buffer.isBuffer(bytes), typeof text);

// Cancellation: the promise rejects with an AbortError
const controller = new AbortController();
const pending = readFile(file, { signal: controller.signal });
controller.abort();
try { await pending; } catch (err) { console.log('aborted    :', err.name, err.code); }

// Reading into memory you already own (v26.4.0 and later)
try {
  const scratch = Buffer.alloc(16 * 1024);
  const view = await readFile(file, { buffer: scratch });
  console.log('buffer opt :', view.length, 'bytes, shares memory:', view.buffer === scratch.buffer);
} catch (err) {
  console.log('buffer opt :', err.code ?? err.name);
}

// A directory is not a file
try { await readFile(new URL('.', import.meta.url)); } catch (err) { console.log('directory  :', err.code, err.syscall); }
Output on Node.js 26 and 24
true string
aborted    : AbortError ABORT_ERR
buffer opt : 247 bytes, shares memory: true
directory  : EISDIR read
Output on Node.js 22 (the buffer option is ignored, so the result is a fresh Buffer)
true string
aborted    : AbortError ABORT_ERR
buffer opt : 247 bytes, shares memory: false
directory  : EISDIR read

Four practical points follow from the reference documentation and from what was observed:

  • Reading a directory fails with EISDIR on macOS, Linux and Windows; the documentation notes that FreeBSD returns a representation of the directory instead.

  • Descriptors are accepted in place of paths by the callback and synchronous forms. A descriptor is not closed for you, and reading begins at its current position, so a descriptor that has already been read from returns only the remainder.

  • A UTF-8 byte order mark is not stripped. Decoding a file that begins with one gives a string whose first character is U+FEFF, and JSON.parse() rejects it (observed); strip it with text.replace(/^/, '') when the files come from editors that add it.

  • readFile() has no timeout and no size cap other than the one in the next section, so never point it at a path taken from a request without validating the path (see Links: symbolic, hard, and why paths are not trustworthy).

How readFile reads: chunks, not one gulp

The documentation’s performance notes explain what happens inside: readFile() reads the file into memory one chunk at a time, letting the event loop turn between chunks. For regular files each read handles 512 KiB; when Node.js cannot determine a size (a pipe, for instance) each read handles 64 KiB. That is kind to other pool users but slower than one big read, and the documentation says that code needing the fastest possible read should call fs.read() itself and manage the buffer. Memory is the other cost: the whole content is resident, plus any string decoded from it.

The 2 GiB limit

readFile() refuses files larger than 2 GiB with ERR_FS_FILE_TOO_LARGE. The error catalogue is explicit that this is not a limit of Buffer but an internal I/O constraint, and recommends fs.createReadStream() for larger files. The experiment creates a sparse file of 2 GiB plus one byte (it occupies almost no disk space on common file systems, which is why the test is cheap) and tries both approaches.

The readFile ceiling, and the stream that has no ceiling
// The readFile ceiling is 2 GiB, a limit of the I/O layer, not of Buffer
import { open, readFile, rm, stat } from 'node:fs/promises';
import { constants } from 'node:buffer';
import { createReadStream } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const path = join(tmpdir(), 'bookshelf-sparse.bin');
const size = 2 ** 31 + 1;                          // 2 GiB + 1 byte, created as a sparse file
const handle = await open(path, 'w');
try {
  await handle.truncate(size);                     // no real disk space is used on most file systems
} finally {
  await handle.close();
}

console.log('Buffer.constants.MAX_LENGTH :', constants.MAX_LENGTH);
console.log('file size                   :', (await stat(path)).size);

try {
  await readFile(path);
} catch (err) {
  console.log('readFile                    :', err.code);
}

let total = 0;
for await (const chunk of createReadStream(path)) total += chunk.length;
console.log('createReadStream total      :', total);
await rm(path);
Output on Node.js 26, 24 and 22
Buffer.constants.MAX_LENGTH : 9007199254740991
file size                   : 2147483649
readFile                    : ERR_FS_FILE_TOO_LARGE
createReadStream total      : 2147483649

Buffer.constants.MAX_LENGTH is far larger than 2 GiB, so raising a Buffer limit cannot help: the check lives in readFile(). A file does not have to reach 2 GiB to cause trouble, either. Parsing a large JSON file also needs the decoded string and the parsed objects, and a server that reads several large files at once multiplies all of it. The remedies, in order of effort, are a stream (see Streams), reading ranges through a FileHandle (see File descriptors and FileHandle), and a line-oriented reader for text.

Reading less than everything

Table 8. Ways to read part of a file or to read incrementally
Need API Notes

A byte range

filehandle.read(buffer, offset, length, position), or createReadStream(path, { start, end })

start and end are inclusive and count from 0.

A text file line by line

filehandle.readLines() (a readline interface over the handle)

See Console, Readline & Terminal I/O; works with for await.

Everything, in chunks, with back-pressure

fs.createReadStream(), filehandle.createReadStream()

Default highWaterMark of 64 KiB; see Streams.

A web-platform stream

filehandle.readableWebStream() (stable since v24.0.0 and v22.17.0)

Byte-oriented; see Web Streams & Interop.

A lazy Blob backed by the file

fs.openAsBlob()

See Two more file-backed APIs.

Writing and appending

writeFile() creates the file if needed and replaces its content if it exists; appendFile() adds to the end, creating the file if needed. Both come in all three styles. The data may be a string, a Buffer, a typed array or a DataView (the promise form also takes an iterable or async iterable). Other values are not converted: passing a plain object fails with ERR_INVALID_ARG_TYPE (observed), so serialise with JSON.stringify() first.

Table 9. Options of writeFile() and appendFile()
Option Meaning Default and notes

encoding

How a string is converted to bytes. Ignored when the data is already bytes.

'utf8'

mode

Permission bits of a newly created file. Ignored when the file already exists.

0o666, reduced by the process umask; see Permissions, modes and the umask.

flag

How the file is opened; see Flags.

'w' for writeFile(), 'a' for appendFile()

flush

If true, flush the data to the device (fsync) after a successful write, before the promise resolves.

false; added in v21.0.0 and v20.10.0 (v21.1.0 for appendFile()).

signal

An AbortSignal. Cancellation is best effort and some data is likely still written.

None

Replace, append, create-only and the mode of a new file
// Writing: replace, append, create-only, and the file mode of a new file
import { writeFile, appendFile, readFile, stat, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const dir = await mkdtemp(join(tmpdir(), 'bookshelf-'));
const file = join(dir, 'reading-list.txt');

try {
  await writeFile(file, 'Efficient Streams\n');                 // flag 'w': create or truncate
  await writeFile(file, 'Projects in Practice\n');              // replaces, does not append
  console.log(JSON.stringify(await readFile(file, 'utf8')));

  await appendFile(file, 'Event Loops Explained\n');            // flag 'a': create if needed, add at the end
  console.log(JSON.stringify(await readFile(file, 'utf8')));

  try {
    await writeFile(file, 'overwrite?\n', { flag: 'wx' });      // 'x': fail if the path exists
  } catch (err) {
    console.log('wx on existing file ->', err.code);
  }

  const secret = join(dir, 'token.txt');
  await writeFile(secret, 'placeholder\n', { mode: 0o600 });     // mode only applies to a newly created file
  console.log('new file mode  :', ((await stat(secret)).mode & 0o777).toString(8));
  await writeFile(secret, 'again\n', { mode: 0o644 });           // existing file: mode ignored
  console.log('after rewrite  :', ((await stat(secret)).mode & 0o777).toString(8));
} finally {
  await rm(dir, { recursive: true, force: true });
}
Output on Node.js 26, 24 and 22
"Projects in Practice\n"
"Projects in Practice\nEvent Loops Explained\n"
wx on existing file -> EEXIST
new file mode  : 600
after rewrite  : 600

The first writeFile() was replaced by the second, not extended; appendFile() added a line; the 'wx' write failed with EEXIST instead of overwriting; and the mode given in the third write had no effect because the file already existed. That last behaviour is why changing permissions on an existing file needs chmod().

Flags

The flag option takes a string or a number. The strings are mnemonic (r read, w write, a append, + add the other direction, x exclusive, s synchronous), and the table below is the documented list.

Table 10. File system flags
Flag Meaning Creates the file?

'r'

Read. Fails if the file does not exist.

No

'r+'

Read and write. Fails if the file does not exist. This is the flag for modifying a file in place.

No

'w'

Write. Creates the file or truncates it.

Yes

'wx', 'wx+'

Like 'w' and 'w+' but fail if the path exists.

Yes, exclusively

'w+'

Read and write; creates or truncates.

Yes

'a'

Append. Writes always go to the end.

Yes

'ax', 'ax+'

Like 'a' and 'a+' but fail if the path exists.

Yes, exclusively

'a+'

Read and append.

Yes

'as', 'as+', 'rs', 'rs+'

Synchronous mode. 'rs+' bypasses the local cache, which matters mainly on NFS mounts and costs real I/O performance; it does not make the call itself synchronous.

'as', 'as+': yes; 'rs', 'rs+': no

Numeric flags are the O_* constants of fs.constants (O_RDONLY, O_CREAT, O_EXCL, O_APPEND, O_TRUNC, O_NOFOLLOW, O_SYNC and more) and can be combined with |; on Windows only a subset is supported and the others are translated or unavailable. Four platform details from the documentation are worth remembering:

  • The exclusive flag x maps to O_EXCL and "might not work with network file systems"; do not build a distributed lock on it.

  • On Linux, positional writes do not work on a file opened in append mode: the kernel ignores the position and always appends.

  • Opening a directory with 'a+' fails with EISDIR on macOS and Linux but returns a descriptor on Windows and FreeBSD.

  • On Windows, opening an existing hidden file with 'w' fails with EPERM; use 'r+' and truncate afterwards.

Appending and exporting

Appending creates the file on its own, so a preceding existsSync() check is unnecessary and racy. The pattern for a CSV export that needs a header exactly once uses the exclusive flag to decide who writes it:

A CSV export with the header written once
// A CSV export: create the file with its header only once, then append rows, with no existsSync() check
import { writeFile, appendFile, readFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

async function appendRow(path, row) {
  try {
    await writeFile(path, 'isbn,title,year\n', { flag: 'wx' });   // succeeds only for the very first caller
  } catch (err) {
    if (err.code !== 'EEXIST') throw err;
  }
  await appendFile(path, `${row.join(',')}\n`);
}

const dir = await mkdtemp(join(tmpdir(), 'bookshelf-'));
const csv = join(dir, 'export.csv');
try {
  await appendRow(csv, ['978-0-00-000001-1', 'Efficient Streams', 2025]);
  await appendRow(csv, ['978-0-00-000002-8', 'Projects in Practice', 2025]);
  process.stdout.write(await readFile(csv, 'utf8'));
} finally {
  await rm(dir, { recursive: true, force: true });
}
Output on Node.js 26, 24 and 22
isbn,title,year
978-0-00-000001-1,Efficient Streams,2025
978-0-00-000002-8,Projects in Practice,2025

The join(',') is only safe for fields that contain no commas, quotes or newlines; real data needs quoting (RFC 4180) or a CSV library, and spreadsheet users may need protection against formulas in cell values. The point of the example is the file handling.

Concurrent writes and durable, atomic updates

Three rules from the reference documentation shape every write path:

  • writeFile() is not safe to call several times on the same file without waiting for the previous call to finish, in any of the three styles. The documentation recommends createWriteStream() for that situation. It could not be provoked deterministically in the test (two simultaneous 4 MB writes came out intact), which is exactly why it is dangerous: the failure depends on timing.

  • The promise API "is not synchronized or threadsafe", and care must be taken with concurrent modifications of the same data.

  • A successful write means the operating system accepted the data, not that it reached the disk. flush: true (or filehandle.sync() and fs.fsync()) asks for it to be pushed to the device.

The first rule is solved by serialising the writers. The second and third are solved together by the classic recipe for replacing a file so that readers see either the old or the new version, never half of a new one: write a temporary file in the same directory, flush it, and rename it over the target.

Serialising appends from many callers
// Serialise appends to one file: the next write starts only after the previous one finished
import { appendFile, readFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

function createLogger(path) {
  let tail = Promise.resolve();
  return (line) => {
    tail = tail.then(() => appendFile(path, `${line}\n`));
    return tail;
  };
}

const dir = await mkdtemp(join(tmpdir(), 'bookshelf-'));
const file = join(dir, 'access.log');
try {
  const log = createLogger(file);
  await Promise.all(Array.from({ length: 5 }, (_, i) => log(`request ${i}`)));
  console.log((await readFile(file, 'utf8')).trim().split('\n'));
} finally {
  await rm(dir, { recursive: true, force: true });
}
Output on Node.js 26, 24 and 22
[ 'request 0', 'request 1', 'request 2', 'request 3', 'request 4' ]
Replacing a file atomically with a temporary file and rename
// Replace a file atomically: write a temporary sibling, flush it, rename it over the target
import { writeFile, rename, rm, readFile, readdir, mkdtemp } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { basename, dirname, join } from 'node:path';

async function writeFileAtomic(path, data) {
  // Same directory, so the rename never crosses a file system boundary
  const temp = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
  try {
    await writeFile(temp, data, { flush: true });   // fsync before the rename
    await rename(temp, path);                       // atomic replace on POSIX
  } catch (err) {
    await rm(temp, { force: true });
    throw err;
  }
}

const dir = await mkdtemp(join(tmpdir(), 'bookshelf-'));
const target = join(dir, 'catalog.json');
try {
  await writeFileAtomic(target, JSON.stringify([{ title: 'v1' }]));
  await writeFileAtomic(target, JSON.stringify([{ title: 'v2' }]));
  console.log(await readFile(target, 'utf8'));
  console.log(await readdir(dir));                  // no temporary file left behind
} finally {
  await rm(dir, { recursive: true, force: true });
}
Output on Node.js 26, 24 and 22
[{"title":"v2"}]
[ 'catalog.json' ]

The temporary file lives in the target’s directory because rename() is only atomic inside one file system (POSIX rename(2); the Node.js documentation says only that an existing target is overwritten, and that a directory at the target is an error). Windows may refuse to rename over a file that another process has open, so test that platform if it matters. Between processes, the exclusive 'wx' flag is a lightweight way to claim a name, but it is not a lock service (see Flags).

When data is large or produced over time (logs, exports, uploads), stop using writeFile() and write through a stream so that memory stays flat: Streams and Implementing Custom Streams cover createWriteStream(), back-pressure and pipeline().

File descriptors and FileHandle

Before Node.js can read or write a file it must open it, and opening returns a handle by which the operating system tracks that open file. On POSIX systems this is a small integer, the file descriptor, taken from a table the kernel keeps for each process; Windows uses a different but similar mechanism, and Node.js hides the difference by giving every open file a numeric descriptor. The Learn article’s point is that the whole fs API is built on this: every operation after open() identifies the file by descriptor.

The callback and synchronous APIs expose the number itself. Two consequences follow. First, the operating system limits how many descriptors a process may hold open, so a descriptor must always be closed, in a finally block when it is synchronous, because a leak grows until opens fail with EMFILE. Second, a number is easy to misuse: once closed, it may be handed out again for another file.

Descriptor-based calls: open, read at a position, always close
// Descriptor-based API: numbers instead of FileHandle objects. Every open needs a close.
import { openSync, readSync, closeSync, fstatSync, open, read, close } from 'node:fs';
import { promisify } from 'node:util';

const path = new URL('./catalog.json', import.meta.url);

// Synchronous: try/finally guarantees the close
const fd = openSync(path, 'r');
try {
  const { size } = fstatSync(fd);
  const head = Buffer.alloc(12);
  const n = readSync(fd, head, 0, head.length, 0);         // position 0
  console.log('sync    :', fd > 2, size, n, JSON.stringify(head.toString()));
} finally {
  closeSync(fd);
}

// Callback style, promisified with util.promisify when you need await
const openP = promisify(open);
const readP = promisify(read);
const closeP = promisify(close);
const fd2 = await openP(path, 'r');
try {
  const { bytesRead, buffer } = await readP(fd2, Buffer.alloc(12), 0, 12, 2);
  console.log('promisified:', bytesRead, JSON.stringify(buffer.toString()));
} finally {
  await closeP(fd2);
}

// The descriptor is gone after close: using it again fails
try { readSync(fd, Buffer.alloc(1), 0, 1, 0); } catch (err) { console.log('after close:', err.code); }
Output on Node.js 26, 24 and 22
sync    : true 247 12 "[\n  { \"isbn\""
promisified: 12 "  { \"isbn\": "
after close: EBADF

The promisified form at the end is what util.promisify() produces from a callback function (the Learn article shows the same with open); fs.read() is one of the functions whose promisified result is an object with bytesRead and buffer. In new code you would use fs/promises and a FileHandle instead. See The util Module for promisify().

FileHandle: the promise API’s descriptor

fsPromises.open(path, flags, mode) resolves to a FileHandle, an object that wraps the numeric descriptor (the fd property) and an event emitter that emits 'close'. Its methods mirror the descriptor-based functions without the fd argument, and add conveniences.

Table 11. FileHandle methods by purpose
Purpose Methods

Read

read() (into a buffer, at an optional position), readFile(), readv() (several buffers), readLines(), createReadStream(), readableWebStream()

Write

write() (a buffer or a string), writeFile(), appendFile(), writev(), createWriteStream()

Metadata

stat(), chmod(), chown(), utimes()

Size and durability

truncate(len), sync() (flush data and metadata to the device, like fsync(2)), datasync() (flush data, not all metadata)

Lifecycle

close(), the 'close' event, [Symbol.asyncDispose]() (used by await using)

Experimental (Stability 1, since v25.9.0)

pull(), pullSync() and writer(), which connect a handle to the stream/iter module covered in Implementing Custom Streams

The position argument of read() and write() is what makes a handle powerful. If it is null (or -1 for reads) the operation uses and advances the file’s current position, like sequential I/O; if it is a number, the operation reads or writes at exactly that offset and leaves the current position alone (pread and pwrite semantics). That makes record-oriented files simple:

Fixed-size records updated in place through a FileHandle
// A FileHandle wraps a file descriptor: open once, then read and write at explicit positions
import { open, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const dir = await mkdtemp(join(tmpdir(), 'bookshelf-'));
const path = join(dir, 'records.dat');
const RECORD = 16;                                   // fixed-size records: 16 bytes each

const pad = (s) => Buffer.from(s.padEnd(RECORD - 1) + '\n');
let handle;
try {
  handle = await open(path, 'w+');                   // read and write, create or truncate
  console.log('fd is a number:', Number.isInteger(handle.fd));

  for (const [i, title] of ['Streams', 'Events', 'Buffers'].entries()) {
    await handle.write(pad(title), 0, RECORD, i * RECORD);   // position: write at byte i * 16
  }
  await handle.write(pad('Timers'), 0, RECORD, 1 * RECORD);  // overwrite record 1 in place

  const record = Buffer.alloc(RECORD);
  const { bytesRead } = await handle.read(record, 0, RECORD, 1 * RECORD);
  console.log(bytesRead, JSON.stringify(record.toString().trim()));

  const { size } = await handle.stat();
  console.log('size:', size, 'records:', size / RECORD);

  await handle.truncate(RECORD);                     // keep only the first record
  await handle.sync();                               // fsync: ask the OS to push data to the device
  console.log('after truncate:', (await handle.stat()).size);
} finally {
  await handle?.close();                             // always close, even after an error
}
await rm(dir, { recursive: true, force: true });
Output on Node.js 26, 24 and 22
fd is a number: true
16 "Timers"
size: 48 records: 3
after truncate: 16

Two rules from the documentation apply to writing through a handle. Do not start several write() calls on the same handle without waiting for each (for a stream of writes use createWriteStream()), and on Linux a handle opened in append mode ignores the position argument and always appends.

Reading a byte range and a web stream from one handle
// A byte range and a web stream from one FileHandle
import { open } from 'node:fs/promises';

const handle = await open(new URL('./catalog.json', import.meta.url));
try {
  // start and end are both inclusive
  let range = '';
  for await (const chunk of handle.createReadStream({ start: 4, end: 13, encoding: 'utf8', autoClose: false })) range += chunk;
  console.log(JSON.stringify(range));

  const web = handle.readableWebStream();                    // a byte-oriented ReadableStream
  let bytes = 0;
  for await (const chunk of web) bytes += chunk.byteLength;
  console.log('web stream bytes:', bytes);
} finally {
  await handle.close();
}
Output on Node.js 26, 24 and 22
"{ \"isbn\": "
web stream bytes: 247

The first number pair uses start: 4, end: 13, both inclusive, so ten bytes were read; autoClose: false keeps the handle open for the second read. By default a stream created from a handle closes it when the stream ends or fails.

Closing: try/finally, await using and the end of close-on-garbage-collection

Always close a handle. A handle that is dropped without close() used to be closed by the garbage collector with a warning. That was a runtime deprecation (DEP0137, since v14.0.0), and it became an error in v25.0.0. The experiment drops a handle and forces two garbage collections (--expose-gc only makes the demonstration deterministic; real programs get no such help):

A handle dropped without close()
// A FileHandle dropped without close() is closed by the garbage collector.
// Node.js 24 and 22 warn (DEP0137); Node.js 26 throws (end-of-life since v25.0.0).
import { open } from 'node:fs/promises';

async function leak() {
  await open(new URL('./catalog.json', import.meta.url));   // never closed
}
await leak();
for (let i = 0; i < 2; i++) {                               // --expose-gc only makes the demo deterministic
  globalThis.gc();
  await new Promise((resolve) => setTimeout(resolve, 100));
}
console.log('still running');
Output on Node.js 26 (the error is thrown from the garbage collector, so the process exits with status 1; the file path is shortened)
[Error: A FileHandle object was closed during garbage collection. This used to be allowed with a deprecation
warning but is now considered an error. Please close FileHandle objects explicitly. File descriptor: 11
(/path/to/catalog.json)] {
  code: 'ERR_INVALID_STATE'
}

Node.js v26.10.0
Output on Node.js 24 and 22 (a deprecation warning, then the program carries on)
(node:78892) Warning: Closing file descriptor 11 on garbage collection
(node:78892) [DEP0137] DeprecationWarning: Closing a FileHandle object on garbage collection is deprecated. [...]
still running

The robust shape is try/finally, as in the earlier examples. On Node.js 24 and later there is a shorter one: FileHandle implements Symbol.asyncDispose, so await using closes it when the block ends, on every path, including an early return or a thrown error (see Async Context & Cancellation for explicit resource management).

await using closes the handle at the end of the scope
// await using closes the handle when the scope ends (Node.js 24+; a syntax error on Node.js 22)
import { open } from 'node:fs/promises';

async function firstLine(path) {
  await using file = await open(path);
  for await (const line of file.readLines()) {      // readline over the handle
    return line;                                    // leaving early still closes the file
  }
}
console.log(await firstLine(new URL('./a1.mjs', import.meta.url)));
Output on Node.js 26 and 24 (a SyntaxError on Node.js 22: use try/finally there)
// One operation, three styles

Too many open files

Descriptors are a finite resource. The limit is a per-process setting of the operating system (on Linux and macOS, ulimit -n). Reaching it produces EMFILE. In the experiment below, a low soft limit alone was not enough to provoke it: Node.js raised it by itself as far as the hard limit allowed (observed), so the hard limit had to be lowered too. Note the cause: not too many calls in flight (the thread pool runs only a few at a time) but too many open handles.

Opening files without closing them hits EMFILE; bounded concurrency does not
// Too many open files at once: EMFILE, and a bounded alternative
import { open, readFile, writeFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const dir = await mkdtemp(join(tmpdir(), 'bookshelf-'));
try {
  const names = Array.from({ length: 3000 }, (_, i) => join(dir, `book-${i}.txt`));
  await Promise.all(names.map((n) => writeFile(n, 'x').catch(() => {})));  // may itself hit the limit
  const handles = [];
  try {
    for (const name of names) handles.push(await open(name));   // never closed, on purpose
    console.log('opened', handles.length);
  } catch (err) {
    console.log('after', handles.length, 'open files:', err.code);
  } finally {
    await Promise.all(handles.map((h) => h.close()));
  }

  // Bounded: at most `limit` files in flight at any time
  async function mapLimit(items, limit, fn) {
    const results = new Array(items.length);
    let next = 0;
    await Promise.all(Array.from({ length: limit }, async () => {
      while (next < items.length) { const i = next++; results[i] = await fn(items[i]); }
    }));
    return results;
  }
  const sizes = await mapLimit(names, 32, async (n) => (await readFile(n)).length);
  console.log('read', sizes.length, 'files, total bytes', sizes.reduce((a, b) => a + b, 0));
} finally {
  await rm(dir, { recursive: true, force: true });
}
Output on Node.js 26 with the hard limit set to 256 in the shell (the number varies with the environment)
after 245 open files: EMFILE
read 3000 files, total bytes 3000

The example is written so that the shell, not the code, sets the limit: run it as (ulimit -Hn 256; ulimit -n 256; node p1.mjs). Reading 3000 files with at most 32 in flight succeeded under the same limit. Bounded concurrency is a general remedy for any batch of file operations, together with closing handles promptly.

Durability: when is the data really on disk?

A completed write() hands the bytes to the operating system’s cache; a power failure can still lose them. sync() and fs.fsync() (flush data and metadata) and datasync() and fs.fdatasync() (flush data, not all metadata) ask for the data to be pushed to the device, and the exact guarantee is "operating system and device specific", as the documentation puts it. The flush: true option of writeFile() and of write streams performs the same flush for you. Use it for data you cannot regenerate (an order, a payment record), not for logs where throughput matters more.

Stats: what is this file?

stat(path) returns an fs.Stats object describing the file, following symbolic links; lstat() describes a link itself; fstat(fd) and filehandle.stat() describe an open file. The class is documented as an object you receive, not one you construct: calling new Stats() is runtime-deprecated since v22.0.0 (DEP0180).

Table 12. The Stats properties you will use
Property or method Meaning

isFile(), isDirectory(), isSymbolicLink(), isFIFO(), isSocket(), isBlockDevice(), isCharacterDevice()

The type of entry. isSymbolicLink() is only ever true for lstat().

size

Size in bytes (0 if the file system cannot report it).

mode

File type and permission bits as a number; stats.mode & 0o777 leaves the permissions (see Permissions, modes and the umask).

uid, gid, nlink, ino, dev, rdev, blksize, blocks

Owner, group, number of hard links, inode, device numbers, block size and allocated blocks.

atime, mtime, ctime, birthtime

Date objects: last access, last data change, last status (inode) change, creation.

atimeMs, mtimeMs, ctimeMs, birthtimeMs

The same moments in milliseconds since the epoch, as numbers.

atimeNs, mtimeNs, ctimeNs, birthtimeNs

Nanoseconds as bigints; only present when called with { bigint: true }.

atimeInstant, mtimeInstant, ctimeInstant, birthtimeInstant

Temporal.Instant values with nanosecond precision, since v26.2.0 (Node.js 26 only).

The four moments differ in meaning: atime changes on reads, mtime when the content is written, ctime when the file’s metadata changes (permissions, links, rename, and also writes), and birthtime is set once at creation. Two caveats apply. Where a file system has no creation time, birthtime may hold the ctime or the epoch (1970-01-01T00:00Z), so it can be later than atime or mtime. And the Date and number forms are independent copies: changing one does not change the other. Millisecond numbers as number lose precision for large timestamps and for sub-millisecond detail, which is what { bigint: true } is for.

Reading and interpreting stats
// What stat() tells you, and how to ask without an exception
import { stat, lstat, access, constants } from 'node:fs/promises';
import { existsSync } from 'node:fs';

const file = new URL('./catalog.json', import.meta.url);
const s = await stat(file);
console.log({
  isFile: s.isFile(),
  isDirectory: s.isDirectory(),
  isSymbolicLink: s.isSymbolicLink(),
  size: s.size,
  mode: (s.mode & 0o777).toString(8),
  mtimeIsDate: s.mtime instanceof Date,
  mtimeMs: typeof s.mtimeMs,
});

const exact = await stat(file, { bigint: true });
console.log('bigint:', typeof exact.size, typeof exact.mtimeMs, typeof exact.mtimeNs);

// A missing entry: undefined instead of ENOENT (async option since v25.7.0)
const missing = new URL('./nope.txt', import.meta.url);
try {
  console.log('throwIfNoEntry:', await stat(missing, { throwIfNoEntry: false }));
} catch (err) {
  console.log('throwIfNoEntry option not honoured:', err.code);
}

// Existence checks (only for reporting; do not use them before opening)
console.log('existsSync   :', existsSync(file), existsSync(missing));
await access(file, constants.R_OK).then(() => console.log('access R_OK  : ok'));
console.log('Instant props:', 'mtimeInstant' in s, typeof s.mtimeInstant);
Output on Node.js 26 (on 24 the last line is false undefined; on 22 the throwIfNoEntry line is an ENOENT error and the last line is false undefined)
{
  isFile: true,
  isDirectory: false,
  isSymbolicLink: false,
  size: 247,
  mode: '644',
  mtimeIsDate: true,
  mtimeMs: 'number'
}
bigint: bigint bigint bigint
throwIfNoEntry: undefined
existsSync   : true false
access R_OK  : ok
Instant props: true object

throwIfNoEntry: false makes stat() return undefined for a missing entry instead of throwing ENOENT (the option reached the promise and callback forms in v25.7.0 and v24.15.0, and existed for the synchronous form before). It answers "does it exist, and what is it?" in one call. What it must not become is a guard before opening.

Do not check, then act

The documentation advises against using stat() to test whether a file exists before opening, reading or writing it: open it and handle the error. The same holds for access() (the documentation calls the access-then-open sequence a race condition) and for existsSync(). Between the check and the use another process, or another request in your own server, can create, delete or replace the file, so the check is not a guarantee, only an extra system call.

Table 13. Existence and permission checks, and what to use instead
Instead of Do this

if (existsSync(p)) readFileSync(p)

Call readFile(p) and handle ENOENT.

if (!existsSync(p)) writeFileSync(p, x)

Call writeFile(p, x, { flag: 'wx' }) and handle EEXIST.

if (!existsSync(dir)) mkdirSync(dir)

Call mkdir(dir, { recursive: true }), which succeeds if the directory exists.

fs.exists(p, cb)

Deprecated (DEP0034). Use access() or stat() for a one-off question, never for a guard.

fs.existsSync(p) with a non-string argument

Passing invalid argument types is runtime-deprecated since v24.0.0 (DEP0187) and will throw in a future version.

Reserve access() for the cases where the answer itself is the goal (a diagnostic, a setup check) and stat() for when you need the metadata. access(path, mode) tests F_OK, or a combination of R_OK, W_OK and X_OK, taken from fs.constants (the direct fs.F_OK and friends were removed in v25.0.0, DEP0176).

How much room is left?

statfs() describes the mounted file system rather than a file, returning an fs.StatFs object with block counts and sizes. It answers questions such as "is there space for this upload?" before you start writing.

Free space on the volume that holds a directory
// How much room is left on the volume that holds a directory?
import { statfs } from 'node:fs/promises';
import { tmpdir } from 'node:os';

const info = await statfs(tmpdir(), { bigint: true });     // bigint: block counts can exceed 2^53 on large volumes
const freeBytes = info.bavail * info.bsize;                 // bavail: blocks available to unprivileged users
console.log(`available: ${(Number(freeBytes) / 2 ** 30).toFixed(1)} GiB`);
console.log('fields   :', Object.keys(info).join(', '));
Output on Node.js 26 (24 and 22 print the same available figure; frsize is in the field list from v26.1.0 only)
available: 476.4 GiB
fields   : type, bsize, frsize, blocks, bfree, bavail, files, ffree

bavail counts blocks available to unprivileged users, and bfree counts all free blocks. Multiply either by bsize to get bytes, as the documentation does. The bigint option avoids losing precision on very large volumes. The figure is a hint: another process can fill the disk right after the call.

Permissions, modes and the umask

POSIX permissions are three groups (owner, group, others) of three bits (read 4, write 2, execute 1), written as three octal digits: 0o640 is read and write for the owner, read for the group, nothing for others. In JavaScript, use the 0o prefix; 640 is a different number. When a file is created, the mode option (default 0o666 for files) is reduced by the process umask, a mask of bits to remove, so a umask of 0o022 turns 0o666 into 0o644. The mode argument is only applied to newly created files.

Table 14. The octal digits and the constants behind them
Digit Meaning Constant for that bit (owner)

4

read

fs.constants.S_IRUSR (0o400)

2

write

fs.constants.S_IWUSR (0o200)

1

execute (for a directory: search it)

fs.constants.S_IXUSR (0o100)

The group and other variants are S_IRGRP, S_IWGRP, S_IXGRP and S_IROTH, S_IWOTH, S_IXOTH, and S_IRWXU, S_IRWXG and S_IRWXO combine the three. Values above 0o777 may behave differently across platforms, which is why the set-user-ID, set-group-ID and sticky constants are not exposed. On Windows only the write permission can be changed and there is no owner/group/other distinction.

Umask, chmod, access() and a denied read
// Permissions: umask and the mode of a new file, chmod, access(), and what a denied read looks like
import { writeFile, chmod, stat, access, readFile, mkdtemp, rm, constants } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const octal = async (path) => ((await stat(path)).mode & 0o777).toString(8).padStart(3, '0');
const dir = await mkdtemp(join(tmpdir(), 'bookshelf-'));
const file = join(dir, 'report.txt');
try {
  const previous = process.umask(0o077);              // setting a umask returns the old one
  await writeFile(file, 'q3 numbers\n');              // default mode 0o666 minus the umask bits
  console.log('umask 077 ->', await octal(file));
  process.umask(previous);

  await chmod(file, 0o400);                           // owner may read, nobody may write
  console.log('chmod 400  ->', await octal(file));
  await access(file, constants.R_OK).then(() => console.log('R_OK        : allowed'));
  await access(file, constants.W_OK).catch((err) => console.log('W_OK        :', err.code));
  await writeFile(file, 'overwrite\n').catch((err) => console.log('writeFile   :', err.code, err.syscall));

  await chmod(file, 0o000);
  await readFile(file).catch((err) => console.log('readFile    :', err.code, err.syscall));
} finally {
  await rm(dir, { recursive: true, force: true });     // removing needs the directory's permissions, not the file's
}
Output on Node.js 26, 24 and 22 (run as an ordinary user; the last two failures do not occur for the superuser)
umask 077 -> 600
chmod 400  -> 400
R_OK        : allowed
W_OK        : EACCES
writeFile   : EACCES open
readFile    : EACCES open

Reading the output: the umask removed the group and other bits from the new file; chmod() set exactly the bits given; access(R_OK) passed and access(W_OK) failed with EACCES; and opening a file whose bits grant nothing fails with EACCES from the open system call, not from read. process.umask(mask) sets the mask and returns the previous one; calling it with no argument is documentation-deprecated (DEP0139), because that form writes the process-wide umask twice, which introduces a race between threads, and the documentation lists no safe cross-platform alternative. Change it at start-up, never in the middle of concurrent work, because it applies to the whole process.

Practical guidance:

  • Create secrets with an explicit restrictive mode (0o600) at creation time, so there is no window in which the file is more widely readable than intended.

  • chown(path, uid, gid) and chmod() change an existing file; lchown() and lutimes() act on a link itself. The lchmod family is deprecated (DEP0035 and DEP0036).

  • The operating system checks permissions when a file is opened: a descriptor that is already open kept working after chmod(path, 0) while a new open failed with EACCES (observed).

  • On file systems without Unix permissions (FAT, some network and USB volumes) chmod() can appear to succeed and have no effect; see Different file systems behave differently.

The permission model: a second layer

Operating-system permissions say what the user running Node.js may do. Node.js adds an opt-in layer that restricts what this process may do even when the user could do more: the permission model (Stability 2, Stable). Started with --permission, a process loses access to the file system unless granted with --allow-fs-read and --allow-fs-write, and denied calls fail with ERR_ACCESS_DENIED. The model is a seat belt for code you trust but want to contain, not a sandbox for hostile code; see Permission Model for the full set of flags and limits.

Running the same file reads under --permission
// Run with: node --permission --allow-fs-read="$PWD" t1.mjs
import { readFile, writeFile } from 'node:fs/promises';

const catalog = new URL('./catalog.json', import.meta.url);
console.log('read inside  :', (await readFile(catalog)).length, 'bytes');
await readFile('/etc/hosts').catch((err) => console.log('read outside :', err.code, err.permission, err.resource));
await writeFile(new URL('./out.txt', import.meta.url), 'x').catch((err) => console.log('write        :', err.code, err.permission));
console.log('has write?   :', process.permission.has('fs.write', new URL('./', import.meta.url).pathname));
Output on Node.js 26, 24 and 22 (run with node --permission --allow-fs-read="$PWD" t1.mjs)
read inside  : 247 bytes
read outside : ERR_ACCESS_DENIED FileSystemRead /etc/hosts
write        : ERR_ACCESS_DENIED FileSystemWrite
has write?   : false

A symbolic link is a small file whose content is a path; the operating system substitutes it when the path is used. A hard link is a second directory entry for the same data. The API follows the POSIX names: symlink(target, path), link(existing, new), readlink(), realpath(), unlink().

Table 15. Which call looks at the link, and which follows it
Call Behaviour

stat(), readFile(), open()

Follow symbolic links to the target.

lstat()

Describes the link itself; isSymbolicLink() is true.

readlink()

Returns the path stored in the link, exactly as it was given (relative targets are relative to the link’s directory).

realpath()

Resolves every link and . or .. segment into an absolute path and fails if something is missing. realpath.native() asks the operating system instead; it supports only paths that convert to UTF-8 strings.

unlink()

Removes one directory entry. For a symbolic link it removes the link, not the target.

copyFile()

Follows links: copying a link copies its target, and writing to a destination that is a link overwrites the target unless COPYFILE_EXCL is set.

On Windows symlink() takes a third argument, type ('file', 'dir' or 'junction'), which other platforms ignore; Node.js detects the type when it is null, and uses 'file' if the target does not exist. Junctions need an absolute destination and only point at directories.

Symbolic links, hard links, dangling links and loops
// Symbolic links, hard links, and why stat() and lstat() disagree
import { writeFile, symlink, link, readlink, realpath, stat, lstat, unlink, rm, mkdtemp, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const dir = await realpath(await mkdtemp(join(tmpdir(), 'bookshelf-')));   // realpath: /tmp is itself a link on macOS
try {
  const real = join(dir, 'catalog-2025.json');
  const alias = join(dir, 'catalog.json');
  const hard = join(dir, 'catalog-copy.json');
  await writeFile(real, '[]\n');

  await symlink('catalog-2025.json', alias);          // target is stored as given (relative here)
  await link(real, hard);                             // a second name for the same data

  console.log('readlink    :', await readlink(alias));
  console.log('stat  alias :', (await stat(alias)).isSymbolicLink(), '(follows the link)');
  console.log('lstat alias :', (await lstat(alias)).isSymbolicLink(), '(looks at the link itself)');
  console.log('realpath ok :', (await realpath(alias)) === real);
  const a = await stat(real, { bigint: true });
  const b = await stat(hard, { bigint: true });
  console.log('hard link   : same inode', a.ino === b.ino, ', nlink', (await stat(real)).nlink);

  await unlink(real);                                 // remove one name; data stays reachable through the hard link
  console.log('copy still  :', JSON.stringify(await readFile(hard, 'utf8')));
  console.log('alias now   :', await stat(alias).catch((err) => err.code), '(dangling link)');
  console.log('lstat alias :', (await lstat(alias)).isSymbolicLink());

  await symlink('loop-b', join(dir, 'loop-a'));
  await symlink('loop-a', join(dir, 'loop-b'));
  console.log('loop        :', await readFile(join(dir, 'loop-a')).catch((err) => err.code));
} finally {
  await rm(dir, { recursive: true, force: true });
}
Output on Node.js 26, 24 and 22
readlink    : catalog-2025.json
stat  alias : false (follows the link)
lstat alias : true (looks at the link itself)
realpath ok : true
hard link   : same inode true , nlink 2
copy still  : "[]\n"
alias now   : ENOENT (dangling link)
lstat alias : true
loop        : ELOOP

Notes on the output: the alias reports isSymbolicLink() as false through stat() because stat() followed it, and true through lstat(). After the original name was unlinked, the alias dangled (stat fails with ENOENT, lstat still works) while the hard link kept the data alive. Two links pointing at each other end in ELOOP. realpath() is called on the temporary directory first because /tmp and /var are themselves links on macOS, so the unresolved path would not compare equal to the resolved one.

Untrusted paths: traversal and symlink escape

When part of a path comes from outside (a request, an upload name, an archive entry), join(base, name) is not enough: name can be ../../etc/passwd, and the result is simply a string; path.resolve() and path.normalize() do not touch the file system, so they cannot see symbolic links. A containment check has to resolve links too and then compare the real locations:

Reading a file only if it really lives inside the base directory
// Resolve an untrusted name inside a base directory, following symbolic links, and refuse anything that escapes
import { readFile, realpath, writeFile, symlink, mkdir, mkdtemp, rm } from 'node:fs/promises';
import { isAbsolute, join, relative, sep } from 'node:path';
import { tmpdir } from 'node:os';

async function readInside(baseDir, untrustedName) {
  if (untrustedName.includes('\0')) throw new Error('invalid name');
  const base = await realpath(baseDir);
  const target = await realpath(join(base, untrustedName));   // resolves ".." and symbolic links; throws ENOENT if missing
  const rel = relative(base, target);
  if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
    throw Object.assign(new Error('outside the base directory'), { code: 'E_OUTSIDE_BASE' });
  }
  return readFile(target, 'utf8');
}

const root = await mkdtemp(join(tmpdir(), 'bookshelf-'));
try {
  await mkdir(join(root, 'covers'));
  await writeFile(join(root, 'covers', 'a.txt'), 'cover a\n');
  await writeFile(join(root, 'secret.txt'), 'not for clients\n');
  await symlink('../secret.txt', join(root, 'covers', 'sneaky.txt'));

  for (const name of ['a.txt', '../secret.txt', 'sneaky.txt', 'missing.txt']) {
    const result = await readInside(join(root, 'covers'), name).catch((err) => err.code);
    console.log(name.padEnd(15), JSON.stringify(result));
  }
} finally {
  await rm(root, { recursive: true, force: true });
}
Output on Node.js 26, 24 and 22
a.txt           "cover a\n"
../secret.txt   "E_OUTSIDE_BASE"
sneaky.txt      "E_OUTSIDE_BASE"
missing.txt     "ENOENT"

../secret.txt and the symbolic link sneaky.txt (which points at the same file) are both refused, and a name that does not exist reports ENOENT. The check shown is not airtight: between realpath() and readFile() the entry can be swapped, which is the same check-then-act gap described earlier. Reduce it by opening once and operating on the descriptor, by refusing links outright when you can, and by giving the process no write access to the base directory. The O_NOFOLLOW flag does the refusing at the operating-system level:

O_NOFOLLOW makes open() fail on a symbolic link (POSIX; not available on Windows)
// Refuse to follow a symbolic link when opening: O_NOFOLLOW (POSIX; not available on Windows)
import { open, symlink, writeFile, mkdtemp, rm, constants } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const dir = await mkdtemp(join(tmpdir(), 'bookshelf-'));
try {
  await writeFile(join(dir, 'real.txt'), 'data\n');
  await symlink('real.txt', join(dir, 'link.txt'));

  const plain = await open(join(dir, 'link.txt'), constants.O_RDONLY);
  console.log('plain open follows the link :', (await plain.readFile('utf8')).trim());
  await plain.close();

  await open(join(dir, 'link.txt'), constants.O_RDONLY | constants.O_NOFOLLOW)
    .catch((err) => console.log('O_NOFOLLOW on the link     :', err.code));
  const direct = await open(join(dir, 'real.txt'), constants.O_RDONLY | constants.O_NOFOLLOW);
  console.log('O_NOFOLLOW on a real file   : ok');
  await direct.close();
} finally {
  await rm(dir, { recursive: true, force: true });
}
Output on Node.js 26, 24 and 22
plain open follows the link : data
O_NOFOLLOW on the link     : ELOOP
O_NOFOLLOW on a real file   : ok

The same defence in depth applies to the process as a whole: running with the permission model restricts every fs call to the granted directories, and a container restricts it further (see Security Best Practices and Permission Model).

Moving, copying, deleting and resizing

Table 16. Whole-file operations
Call What it does Pitfalls

rename(old, new)

Renames or moves. Replaces an existing file at new; an existing directory there is an error.

Fails with EXDEV across file systems or volumes. Independent calls are not ordered.

copyFile(src, dest, mode)

Copies one file. COPYFILE_EXCL refuses to overwrite; COPYFILE_FICLONE tries a copy-on-write clone and falls back to a copy; COPYFILE_FICLONE_FORCE fails if cloning is impossible.

Not atomic: the documentation guarantees nothing, and on an error after the destination was opened Node.js tries to remove it. Follows symbolic links.

unlink(path)

Deletes a file or a symbolic link.

A directory needs rmdir() (must be empty) or rm().

rm(path, { recursive, force })

Deletes files or whole trees; force: true ignores a missing path.

Covered in Directories, Glob & Watch.

truncate(path, len), filehandle.truncate(len)

Cuts or extends a file to len bytes.

fs.truncate() with a descriptor is an error since v24.0.0 (DEP0081): use ftruncate().

utimes(path, atime, mtime)

Sets timestamps; accepts Date objects or seconds. lutimes() acts on a link.

Precision depends on the file system (see Different file systems behave differently).

The EXDEV error ("cross-device link") is the one to plan for. A move that is a cheap rename on one volume becomes a copy followed by a delete across volumes: a Docker volume, a temp directory on another disk, a network mount. The helper below tries rename() first and falls back on EXDEV, deleting the source only after the copy succeeded and refusing to overwrite an existing destination.

A move that works across volumes
// rename() is atomic but cannot cross a file system boundary (EXDEV); fall back to copy + delete
import { rename, copyFile, unlink, writeFile, readFile, mkdtemp, rm, constants } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

async function move(from, to) {
  try {
    await rename(from, to);
    return 'rename';
  } catch (err) {
    if (err.code !== 'EXDEV') throw err;
    await copyFile(from, to, constants.COPYFILE_EXCL);   // refuse to overwrite an existing target
    await unlink(from);                                   // only after the copy succeeded
    return 'copy + unlink';
  }
}

// Pass another volume as the first argument to take the fallback path
const sourceDir = await mkdtemp(join(process.argv[2] ?? tmpdir(), 'move-src-'));
const targetDir = await mkdtemp(join(tmpdir(), 'move-dst-'));
try {
  const from = join(sourceDir, 'cover.png');
  const to = join(targetDir, 'cover.png');
  await writeFile(from, 'png bytes');
  console.log('strategy :', await move(from, to));
  console.log('content  :', await readFile(to, 'utf8'));

  await writeFile(from, 'second copy');
  await copyFile(from, to, constants.COPYFILE_EXCL).catch((err) => console.log('no clobber:', err.code));
} finally {
  await rm(sourceDir, { recursive: true, force: true });
  await rm(targetDir, { recursive: true, force: true });
}
Output on Node.js 26, 24 and 22 (first run on one volume, second with the source directory on a different volume)
strategy : rename
content  : png bytes
no clobber: EEXIST
strategy : copy + unlink
content  : png bytes
no clobber: EEXIST

The second run used the case-sensitive APFS disk image from the file system experiments as the source volume. Recursive copy and delete (cp(), rm()) replaced the npm packages that existed for them; see Directories, Glob & Watch. fs.rmdir() lost its recursive option in v25.0.0 (DEP0147): on Node.js 26 rmdirSync(path, { recursive: true }) throws ERR_INVALID_ARG_VALUE (observed), so use rm(path, { recursive: true, force: true }).

Folders at a glance

Working with directories is covered in Directories, Glob & Watch: readdir() with recursive and Dirent, mkdir() with recursive: true, rm() and cp(), mkdtemp(), fs.glob() and watching. The Learn article on folders lists the basic operations (check whether a folder exists with access(), create with mkdir(), list with readdir(), rename with rename(), delete with rm()). One pattern in that article is worth replacing: it creates a folder inside if (!fs.existsSync(folderName)). That is the check-then-act gap from Do not check, then act; mkdir(folderName, { recursive: true }) does the same job in one call and does not fail when the folder is already there.

Different file systems behave differently

Node.js exposes what the operating system offers, and file systems differ in what they offer. The Learn article on different file systems lists the dimensions: case sensitivity and case preservation, Unicode form (NFC, NFD) preservation, timestamp resolution, extended attributes, inodes, Unix permissions, alternate data streams. Its advice is a small set of rules that this page’s experiments confirm.

  1. Do not infer behaviour from process.platform. A program on macOS may use a case-sensitive volume; a program on Linux may be writing to a USB drive or network share that has no Unix permissions.

  2. Probe instead of guessing. A few cheap experiments (create a file, look for it under another spelling) reveal the behaviour, and the answer can differ per directory because volumes are mounted at different places in one tree.

  3. Do not make your program a lowest common denominator by normalising every name to upper case, every string to NFC and every timestamp to one-second resolution. You could then only coexist with file systems with exactly those limits, and you would collide names that differ only in case or form and corrupt user data.

  4. Adopt a superset approach instead: preserve case, preserve the exact Unicode bytes, keep nanosecond timestamps, keep permissions. When a weaker file system loses information, compensate when you compare, not when you store.

  5. Use normalisation only as a comparison function. Compare a.normalize('NFC') === b.normalize('NFC') (either form works as long as both sides use the same), never overwrite the name you were given; and do not use a case-insensitive comparison on a case-sensitive file system.

The article also states that names and timestamps are user data: a file system may return ABC for a directory created as abc, a Unicode name in a form other than the one you wrote, or a modification time rounded to 2 seconds, and none of that is a Node.js bug, because Node.js returns what the file system stores.

The experiment below runs the probes the article suggests, on three different volumes, and prints what each one does.

Probing a file system for case, Unicode form and timestamp behaviour
// Probe the file system you are on instead of guessing from process.platform
import { writeFile, readdir, stat, utimes, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const dir = await mkdtemp(join(process.argv[2] ?? tmpdir(), 'bookshelf-probe-'));
try {
  // Case sensitivity: create "probe.txt", then ask for "PROBE.TXT"
  await writeFile(join(dir, 'probe.txt'), 'x');
  const caseInsensitive = await stat(join(dir, 'PROBE.TXT')).then(() => true, () => false);

  // Case preservation: what name does the directory listing return?
  await writeFile(join(dir, 'MixedCase.txt'), 'x');
  const listed = (await readdir(dir)).includes('MixedCase.txt');

  // Unicode form: create the NFC name, look for the NFD spelling
  const nfc = 'café.txt';
  const nfd = nfc.normalize('NFD');
  await writeFile(join(dir, nfc), 'x');
  const normalizationInsensitive = await stat(join(dir, nfd)).then(() => true, () => false);
  const names = (await readdir(dir)).filter((n) => n.startsWith('caf'));
  const stored = names[0] === nfc ? 'NFC' : 'NFD';

  // Timestamp resolution: set a mtime with millisecond detail and read it back
  const wanted = new Date(1_444_291_759_414);          // ...414 ms
  await utimes(join(dir, 'probe.txt'), wanted, wanted);
  const mtimeMs = (await stat(join(dir, 'probe.txt'), { bigint: true })).mtimeMs;

  console.log({ caseInsensitive, casePreserved: listed, normalizationInsensitive, storedAs: stored, mtimeMs });
  console.log('NFC length', nfc.length, 'NFD length', nfd.length, 'equal when normalised:', nfc.normalize('NFC') === nfd.normalize('NFC'));
} finally {
  await rm(dir, { recursive: true, force: true });
}
Output on the default APFS volume (identical on Node.js 26, 24 and 22)
{
  caseInsensitive: true,
  casePreserved: true,
  normalizationInsensitive: true,
  storedAs: 'NFC',
  mtimeMs: 1444291759414n
}
NFC length 8 NFD length 9 equal when normalised: true
Table 17. What the same probe reported on three volumes (observed on macOS)
Probe APFS (default, case-insensitive) APFS (case-sensitive) FAT32 disk image

PROBE.TXT finds probe.txt

yes

no

yes

Listing keeps MixedCase.txt as written

yes

yes

yes

NFD spelling finds the NFC-named file

yes

yes

yes

readdir() returns the name as

NFC (as created)

NFC (as created)

NFD (changed by the volume)

A modification time set to …​414 ms reads back as

…​414 ms

…​414 ms

…​758000 ms, i.e. rounded to a 2 second boundary

Unix mode of a new file created with 0o600

600

600

700 and unchanged by chmod(0o444)

Three of those results contradict what a developer would guess from "this is macOS": the default volume is case-insensitive yet case-preserving, the case-sensitive volume still found a file through the other Unicode spelling of its name (observed: it keeps the form you wrote but compares names without regard to form), and the FAT volume changed the stored form of a name, rounded a timestamp, and silently ignored a permission change. Code that works on all of them keeps the name it was given, compares through normalize(), tolerates coarse timestamps and does not rely on chmod() being effective. The same program ran unchanged on all three volumes, which is the point of probing.

Other differences to remember when files cross environments: the 'rs+' flag exists for NFS mounts (it bypasses the local cache, at a real performance cost), the exclusive flag may not work on network file systems, and any mounted volume (a network share, a USB drive, a disk image) brings its own rules to the part of the tree where it is mounted.

Errors

File system errors are ordinary Error objects with code, errno, syscall, path and, for two-path calls, dest. Branch on err.code, never on the message. Errors & Error Handling explains the anatomy; this table covers what the file system produces most often.

The first eight codes in the table are in the documentation’s list of common system errors; EXDEV, ELOOP, EBADF and ENOSPC are standard POSIX errors that were observed in the experiments or are well known, and the last three are Node.js’s own ERR_* codes.

Table 18. File system error codes and what causes them
Code Meaning Typical cause

ENOENT

No such file or directory

Missing file, a wrong working directory, a dangling symbolic link

EEXIST

The path already exists

mkdir() of an existing name, a write with 'wx', COPYFILE_EXCL

EISDIR, ENOTDIR

A directory where a file was expected, or a file where a directory was

Reading a directory; a path that goes through a file

EACCES, EPERM

Permission denied; operation not permitted

Mode bits, ownership, a read-only file system, a hidden file on Windows opened with 'w'

ENOTEMPTY

Directory not empty

rmdir() of a directory that has entries

EMFILE

Too many open files

Unclosed handles

EXDEV

Cross-device link

rename() across volumes

ELOOP

Too many levels of symbolic links

A link cycle, or O_NOFOLLOW on a link

EBADF

Bad file descriptor

Using a descriptor after closing it

ENOSPC

No space left on device

A full volume; check with statfs() for big writes

ERR_FS_FILE_TOO_LARGE

A Node.js error, not an operating-system one

readFile() of more than 2 GiB

ERR_ACCESS_DENIED

A Node.js error

The permission model refused the call

ERR_INVALID_STATE

A Node.js error

A FileHandle closed by the garbage collector (Node.js 25 and later)

Triggering the common errors on purpose
// The errors you will meet: always branch on err.code
import { readFile, writeFile, mkdir, rmdir, rm, mkdtemp, chmod, open } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const dir = await mkdtemp(join(tmpdir(), 'bookshelf-'));
const trial = async (label, fn) => {
  try { await fn(); console.log(label.padEnd(26), 'ok'); }
  catch (err) { console.log(label.padEnd(26), err.code.padEnd(10), err.syscall); }
};
try {
  await writeFile(join(dir, 'file.txt'), 'x');
  await mkdir(join(dir, 'sub'));
  await writeFile(join(dir, 'sub', 'inner.txt'), 'x');
  await writeFile(join(dir, 'locked.txt'), 'x', { mode: 0o000 });

  await trial('read missing file', () => readFile(join(dir, 'missing.txt')));
  await trial('read a directory', () => readFile(join(dir, 'sub')));
  await trial('mkdir existing', () => mkdir(join(dir, 'sub')));
  await trial('create with wx', () => writeFile(join(dir, 'file.txt'), 'y', { flag: 'wx' }));
  await trial('path through a file', () => readFile(join(dir, 'file.txt', 'child')));
  await trial('read without permission', () => readFile(join(dir, 'locked.txt')));
  await trial('rmdir non-empty', () => rmdir(join(dir, 'sub')));
  await trial('open missing for reading', () => open(join(dir, 'nope'), 'r'));
} finally {
  await rm(dir, { recursive: true, force: true });
}
Output on Node.js 26, 24 and 22 (run as an ordinary user, because the superuser can read a mode 000 file)
read missing file          ENOENT     open
read a directory           EISDIR     read
mkdir existing             EEXIST     mkdir
create with wx             EEXIST     open
path through a file        ENOTDIR    open
read without permission    EACCES     open
rmdir non-empty            ENOTEMPTY  rmdir
open missing for reading   ENOENT     open

Two more file-backed APIs

fs.openAsBlob(path) returns a Blob whose data is backed by the file and read lazily, so blob.slice(0, 8).text() reads only those bytes. It is a cheap way to hand a file to any API that accepts a Blob without reading it into memory first. The file must not be modified after the blob is created: changes make reads fail with a DOMException, because Node.js checks the file before each read. It is stable since v24.0.0 and v22.17.0, and fs.openAsBlobSync() joined it in v26.10.0.

fs.Utf8Stream (Stability 1, Experimental, v24.6.0) is an optimised writer for UTF-8 text whose internal buffer can be flushed on demand, which handles EAGAIN correctly and can drop data when the disk is busy, the kind of control a logger wants. Treat the API as unstable and wrap it behind your own function.

A Blob backed by a file, and an experimental buffered log writer
// Two file-backed helpers: a Blob that reads lazily from disk, and an experimental fast log writer
import { openAsBlob, Utf8Stream } from 'node:fs';
import { readFile, mkdtemp, rm } from 'node:fs/promises';
import { once } from 'node:events';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const catalog = new URL('./catalog.json', import.meta.url);
const blob = await openAsBlob(catalog, { type: 'application/json' });   // stable since v24.0.0 / v22.17.0
console.log('blob       :', blob.type, blob.size, 'bytes');
console.log('slice      :', JSON.stringify(await blob.slice(0, 8).text()));   // reads only that range

const dir = await mkdtemp(join(tmpdir(), 'bookshelf-'));
try {
  const log = new Utf8Stream({ dest: join(dir, 'app.log'), sync: false, minLength: 4096 });   // Stability 1 - Experimental
  await once(log, 'ready');
  for (let i = 0; i < 3; i++) log.write(`line ${i}\n`);     // buffered, not yet on disk
  log.end();                                                 // flush, then close
  await once(log, 'finish');
  console.log('log file   :', JSON.stringify(await readFile(join(dir, 'app.log'), 'utf8')));
} finally {
  await rm(dir, { recursive: true, force: true });
}
Output on Node.js 26 and 24 (on Node.js 22 the import of Utf8Stream fails with a SyntaxError)
blob       : application/json 247 bytes
slice      : "[\n  { \"i"
log file   : "line 0\nline 1\nline 2\n"

Where streams fit

createReadStream() and createWriteStream() belong to this module but are the subject of Streams. The facts to carry from here: both accept start (and end, inclusive, for reads) to address a byte range; autoClose and emitClose control closing; a read stream’s default highWaterMark is 64 KiB; the default for write streams was raised in v22.0.0; flush: true flushes a write stream before it closes; and since v26.8.0 a Windows-only windowsHandle option lets either wrap a raw Win32 handle. Use a stream whenever the data is big, unbounded, or produced over time.

Choosing the right tool

Table 19. Which API for which job
Job Reach for

Load a small config or JSON file at start-up

readFileSync() (once, before serving) or await readFile()

Read a small file in a request handler

await readFile() from node:fs/promises

Read a large or unbounded file

createReadStream() with pipeline(), or filehandle.readLines() for text

Read a slice of a file

filehandle.read() with a position, or createReadStream({ start, end })

Save a document

The atomic temp-file-and-rename recipe, with flush: true when it matters

Add records to a log or export

appendFile() serialised through one writer, or a write stream (fs.Utf8Stream where its Experimental status is acceptable)

Create a file only if it is new

writeFile(path, data, { flag: 'wx' })

Update a record in place

open(path, 'r+'), then write() with a position

Find out what a path is

stat() (or lstat() to look at a link), with throwIfNoEntry: false

Check free space before a big write

statfs()

Serve a file the user named

Resolve with realpath(), check containment, consider O_NOFOLLOW, and run under the permission model

Move a file between volumes

rename() with an EXDEV fallback to copyFile() and unlink()

What changed recently

Table 20. Changes to the file system API that the books and older articles predate
Version Change Effect on your code

v26.10.0

fs.openAsBlobSync() added.

A synchronous variant of the lazy file Blob.

v26.8.0

signal option accepted by fsPromises.stat(), fsPromises.lstat(), fs.lstat() and fs.fstat(); windowsHandle option for createReadStream() and createWriteStream().

Stat calls can be cancelled; Windows only for the handle option.

v26.4.0 (and v24.19.0)

buffer option of readFile(): read into a buffer you supply or allocate with a function.

Avoids an extra allocation; not on Node.js 22.

v26.2.0

Temporal.Instant properties (mtimeInstant and the other three) on fs.Stats.

Nanosecond-precision timestamps without bigint; Node.js 26 only.

v26.1.0

filehandle.stat() accepts signal; statfs.frsize added; fs.watch() gains throwIfNoEntry.

Small additions.

v25.9.0

filehandle.pull(), pullSync() and writer() (Experimental, tied to stream/iter).

Do not depend on them in production.

v25.7.0 (and v24.15.0)

throwIfNoEntry for the promise and callback forms of stat().

stat() can return undefined for a missing entry.

v25.0.0

End-of-life: a FileHandle closed by garbage collection is an error (DEP0137); fs.F_OK, fs.R_OK, fs.W_OK, fs.X_OK removed (DEP0176); recursive option of rmdir() removed (DEP0147).

Close every handle; use fs.constants; use rm().

v24.6.0

fs.Utf8Stream added (Experimental).

A buffered log writer.

v24.2.0

FileHandle await using support no longer experimental.

Automatic close on Node.js 24 and later.

v24.0.0 (and v22.17.0)

fs.openAsBlob() and filehandle.readableWebStream() marked stable; DEP0187 runtime-deprecates invalid argument types for existsSync(); DEP0081 (truncate() with a descriptor) reaches end-of-life.

Stable lazy blobs and web streams from files.

v22.0.0

Public fs.Stats constructor runtime-deprecated (DEP0180); default highWaterMark of write streams raised.

Do not construct Stats.

v21.0.0 (and v20.10.0)

flush option for writeFile() and write streams (appendFile() followed in v21.1.0).

Durable writes in one call.

Where the books are outdated

Efficient Node.js (Samer Buna, O’Reilly 2025) shows file reading in all three styles and uses a readFile against createReadStream comparison to motivate streams, which is a good introduction and still valid in spirit. Three things need updating. It says that a file above 2 GB cannot be served with readFile by default and hints that the limits can be changed; the documented limit is an internal I/O constraint of readFile() (ERR_FS_FILE_TOO_LARGE), independent of Buffer’s maximum size, so there is no setting to raise and the answer is a stream or ranged reads (The 2 GiB limit). Its coverage of the promise API stops at `readFile(): FileHandle, explicit positions, await using (Node.js 24 and later), durability (flush), flags, modes and the umask, symbolic links and file system differences are not part of its scope. And, written before Node.js 25, it cannot mention the end-of-life changes in What changed recently.

Node.js Projects (Jonathan Wexler, O’Reilly 2025) exports contacts to a CSV file with writeFileSync() and appendFileSync() and suggests guarding the append with existsSync(). Synchronous calls are acceptable in a one-person command-line tool (When synchronous calls are acceptable), but the guard is unnecessary (appending creates the file) and racy (Do not check, then act), the writes would block a server, and the CSV is built by joining strings, which breaks as soon as a title contains a comma. It imports from "fs" rather than "node:fs", which still works; the node: prefix is the convention throughout this section because it states plainly that the module is built in. The permission model for restricting file access gets at most a passing mention.

Older tutorials, including parts of the Node.js Learn site itself, share some habits worth retiring:

Table 21. Patterns to replace
Older pattern Current approach

util.promisify(fs.readFile) or require('fs').promises

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

if (!fs.existsSync(dir)) fs.mkdirSync(dir)

mkdir(dir, { recursive: true })

fs.exists(), or existsSync() as a guard

Open and handle the error; stat() with throwIfNoEntry: false; access() for diagnostics

fs.F_OK, fs.R_OK

fs.constants.F_OK (the old names were removed in v25.0.0)

fs.rmdir(dir, { recursive: true }), or packages for recursive delete and copy

rm(dir, { recursive: true, force: true }) and cp() (stable since v22.3.0)

A FileHandle left for the garbage collector

try/finally or await using

readFile() for every file

A stream or ranged read for big data

Writing a config file in place

Temp file, flush, rename()

Pitfalls checklist

Table 22. Common file system mistakes
Mistake Fix

Using a synchronous call in a request handler

Use the promise form; keep Sync for start-up and scripts.

Starting a dependent call before the first has finished

await it first (the rename-then-stat race).

Leaving a FileHandle or descriptor open

try/finally or await using; on Node.js 25 and later a forgotten handle crashes the process.

Checking existence, then acting

Act and handle ENOENT / EEXIST; use 'wx' and recursive: true.

Relative paths that depend on the working directory

Anchor with import.meta.dirname or new URL(…​, import.meta.url).

Calling writeFile() concurrently on one file

Serialise writers, or use one write stream.

Overwriting a file in place and crashing half-way

Write a temp file in the same directory, flush it, rename it.

Reading unbounded files with readFile()

Streams; remember the 2 GiB limit.

Joining untrusted names to a base path

realpath() plus a containment check, O_NOFOLLOW where possible, the permission model.

Assuming mode changes an existing file, or that chmod() works everywhere

Use chmod(); probe the volume.

Assuming case, Unicode form or timestamp behaviour from the operating system

Probe; preserve what the file system gives you; normalise only to compare.

Decoding a file that starts with a byte order mark

Strip U+FEFF before JSON.parse().

rename() across volumes

Fall back to copy and delete on EXDEV.

References