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
| API or setting | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable |
|
|
Stable |
No longer experimental since v24.2.0. The |
|
Stable |
Added in v26.4.0 and v24.19.0. Not in the Node.js 22 documentation; observed to be silently ignored there. |
|
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). |
|
Stable |
Added in v26.2.0 ( |
|
Stable |
Marked stable in v24.0.0 and v22.17.0. |
|
1 Experimental |
Added in v24.6.0; missing on Node.js 22 (observed). |
|
1 Experimental |
Added in v25.9.0, tied to the experimental |
|
0 Deprecated |
Documentation-only deprecation DEP0034 (since v1.0.0). |
Closing a |
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). |
|
End-of-Life (DEP0176, DEP0147, DEP0081) |
The first two were removed in v25.0.0, the last in v24.0.0. |
The permission model ( |
2 Stable |
Added in v20.0.0; the network flag |
| 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 |
|
Writing files with Node.js |
|
Working with file descriptors in Node.js |
|
Node.js file stats |
|
Working with folders in Node.js |
|
How to work with different filesystems |
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.
| Style | Who does the system call | What the event loop does meanwhile |
|---|---|---|
Promises ( |
A libuv thread pool thread |
Keeps running; your code resumes after the |
Callbacks ( |
A libuv thread pool thread |
Keeps running; the callback is queued when the work completes |
Synchronous ( |
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).
// 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');
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.
| Style | Result and errors | Event loop | Use it for |
|---|---|---|---|
Promises |
Returns a promise; errors reject, with |
Free to continue |
Application code: servers, workers, anything long-running. The default choice. |
Callback |
Last argument is |
Free to continue |
Older code and APIs that predate promises. Convert with |
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()).
Source: Node.js API: File system
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 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);
}
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.
| 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 |
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) |
Source: Node.js API: Process (the 'exit' event), Node.js Learn: Overview of Blocking vs Non-Blocking
The usual shape of a service is therefore synchronous at start-up, asynchronous afterwards:
// 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();
});
/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:
// 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 });
}
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.
| Form | How it is interpreted | Watch out for |
|---|---|---|
String |
UTF-8 text naming an absolute or relative file. A relative path is resolved against |
Backslashes on Windows; the working directory changing between runs. |
|
Always absolute. |
Encoded separators ( |
|
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.
// 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');
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.
| Option | Meaning | Default and notes |
|---|---|---|
|
Decode the bytes into a string ( |
|
|
How to open the file; see Flags. |
|
|
An |
Aborting stops Node.js’s internal buffering, not an individual operating-system request already in flight. |
|
A |
Added in v26.4.0 and v24.19.0; absent on Node.js 22. |
// 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); }
true string
aborted : AbortError ABORT_ERR
buffer opt : 247 bytes, shares memory: true
directory : EISDIR read
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
EISDIRon 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, andJSON.parse()rejects it (observed); strip it withtext.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 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);
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
| Need | API | Notes |
|---|---|---|
A byte range |
|
|
A text file line by line |
|
See Console, Readline & Terminal I/O; works with |
Everything, in chunks, with back-pressure |
|
Default |
A web-platform stream |
|
Byte-oriented; see Web Streams & Interop. |
A lazy |
|
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.
| Option | Meaning | Default and notes |
|---|---|---|
|
How a string is converted to bytes. Ignored when the data is already bytes. |
|
|
Permission bits of a newly created file. Ignored when the file already exists. |
|
|
How the file is opened; see Flags. |
|
|
If |
|
|
An |
None |
// 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 });
}
"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.
| Flag | Meaning | Creates the file? |
|---|---|---|
|
Read. Fails if the file does not exist. |
No |
|
Read and write. Fails if the file does not exist. This is the flag for modifying a file in place. |
No |
|
Write. Creates the file or truncates it. |
Yes |
|
Like |
Yes, exclusively |
|
Read and write; creates or truncates. |
Yes |
|
Append. Writes always go to the end. |
Yes |
|
Like |
Yes, exclusively |
|
Read and append. |
Yes |
|
Synchronous mode. |
|
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
xmaps toO_EXCLand "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 withEISDIRon macOS and Linux but returns a descriptor on Windows and FreeBSD. -
On Windows, opening an existing hidden file with
'w'fails withEPERM; 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: 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 });
}
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 recommendscreateWriteStream()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(orfilehandle.sync()andfs.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.
// 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 });
}
[ 'request 0', 'request 1', 'request 2', 'request 3', 'request 4' ]
// 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 });
}
[{"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).
Source: Node.js API: File system (fs.rename), Node.js API: File system (fsPromises.writeFile, the flush option)
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 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); }
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().
Source: Node.js Learn: Working with file descriptors in Node.js, Node.js API: File system (file descriptors)
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.
| Purpose | Methods |
|---|---|
Read |
|
Write |
|
Metadata |
|
Size and durability |
|
Lifecycle |
|
Experimental (Stability 1, since v25.9.0) |
|
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:
// 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 });
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.
// 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();
}
"{ \"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 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');
[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
(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 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)));
// 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.
// 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 });
}
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).
| Property or method | Meaning |
|---|---|
|
The type of entry. |
|
Size in bytes (0 if the file system cannot report it). |
|
File type and permission bits as a number; |
|
Owner, group, number of hard links, inode, device numbers, block size and allocated blocks. |
|
|
|
The same moments in milliseconds since the epoch, as numbers. |
|
Nanoseconds as bigints; only present when called with |
|
|
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.
// 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);
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
Source: Node.js Learn: Node.js file stats, Node.js API: File system (fs.Stats), Node.js API: File system (stat time values), Node.js API: File system (fs.stat)
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.
| Instead of | Do this |
|---|---|
|
Call |
|
Call |
|
Call |
|
Deprecated (DEP0034). Use |
|
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.
// 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(', '));
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.
| Digit | Meaning | Constant for that bit (owner) |
|---|---|---|
|
read |
|
|
write |
|
|
execute (for a directory: search it) |
|
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.
// 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
}
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.
Source: Node.js API: File system (file modes), Node.js API: File system (fs.chmod), Node.js API: Process (process.umask)
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)andchmod()change an existing file;lchown()andlutimes()act on a link itself. Thelchmodfamily 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 withEACCES(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.
// 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));
read inside : 247 bytes
read outside : ERR_ACCESS_DENIED FileSystemRead /etc/hosts
write : ERR_ACCESS_DENIED FileSystemWrite
has write? : false
Links: symbolic, hard, and why paths are not trustworthy
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().
| Call | Behaviour |
|---|---|
|
Follow symbolic links to the target. |
|
Describes the link itself; |
|
Returns the path stored in the link, exactly as it was given (relative targets are relative to the link’s directory). |
|
Resolves every link and |
|
Removes one directory entry. For a symbolic link it removes the link, not the target. |
|
Follows links: copying a link copies its target, and writing to a destination that is a link overwrites the target unless |
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, 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 });
}
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.
Source: Node.js API: File system (fs.symlink), Node.js API: File system (fs.lstat), Node.js API: File system (fs.realpath)
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:
// 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 });
}
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:
// 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 });
}
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
| Call | What it does | Pitfalls |
|---|---|---|
|
Renames or moves. Replaces an existing file at |
Fails with |
|
Copies one file. |
Not atomic: the documentation guarantees nothing, and on an error after the destination was opened Node.js tries to remove it. Follows symbolic links. |
|
Deletes a file or a symbolic link. |
A directory needs |
|
Deletes files or whole trees; |
Covered in Directories, Glob & Watch. |
|
Cuts or extends a file to |
|
|
Sets timestamps; accepts |
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.
// 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 });
}
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.
-
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. -
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.
-
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.
-
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.
-
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.
// 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 });
}
{
caseInsensitive: true,
casePreserved: true,
normalizationInsensitive: true,
storedAs: 'NFC',
mtimeMs: 1444291759414n
}
NFC length 8 NFD length 9 equal when normalised: true
| Probe | APFS (default, case-insensitive) | APFS (case-sensitive) | FAT32 disk image |
|---|---|---|---|
|
yes |
no |
yes |
Listing keeps |
yes |
yes |
yes |
NFD spelling finds the NFC-named file |
yes |
yes |
yes |
|
NFC (as created) |
NFC (as created) |
NFD (changed by the volume) |
A modification time set to |
|
|
|
Unix mode of a new file created with |
|
|
|
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.
| Code | Meaning | Typical cause |
|---|---|---|
|
No such file or directory |
Missing file, a wrong working directory, a dangling symbolic link |
|
The path already exists |
|
|
A directory where a file was expected, or a file where a directory was |
Reading a directory; a path that goes through a file |
|
Permission denied; operation not permitted |
Mode bits, ownership, a read-only file system, a hidden file on Windows opened with |
|
Directory not empty |
|
|
Too many open files |
Unclosed handles |
|
Cross-device link |
|
|
Too many levels of symbolic links |
A link cycle, or |
|
Bad file descriptor |
Using a descriptor after closing it |
|
No space left on device |
A full volume; check with |
|
A Node.js error, not an operating-system one |
|
|
A Node.js error |
The permission model refused the call |
|
A Node.js error |
A |
// 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 });
}
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.
// 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 });
}
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
| Job | Reach for |
|---|---|
Load a small config or JSON file at start-up |
|
Read a small file in a request handler |
|
Read a large or unbounded file |
|
Read a slice of a file |
|
Save a document |
The atomic temp-file-and-rename recipe, with |
Add records to a log or export |
|
Create a file only if it is new |
|
Update a record in place |
|
Find out what a path is |
|
Check free space before a big write |
|
Serve a file the user named |
Resolve with |
Move a file between volumes |
|
Source: Node.js API: File system
What changed recently
| Version | Change | Effect on your code |
|---|---|---|
v26.10.0 |
|
A synchronous variant of the lazy file |
v26.8.0 |
|
Stat calls can be cancelled; Windows only for the handle option. |
v26.4.0 (and v24.19.0) |
|
Avoids an extra allocation; not on Node.js 22. |
v26.2.0 |
|
Nanosecond-precision timestamps without |
v26.1.0 |
|
Small additions. |
v25.9.0 |
|
Do not depend on them in production. |
v25.7.0 (and v24.15.0) |
|
|
v25.0.0 |
End-of-life: a |
Close every handle; use |
v24.6.0 |
|
A buffered log writer. |
v24.2.0 |
|
Automatic close on Node.js 24 and later. |
v24.0.0 (and v22.17.0) |
|
Stable lazy blobs and web streams from files. |
v22.0.0 |
Public |
Do not construct |
v21.0.0 (and v20.10.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:
| Older pattern | Current approach |
|---|---|
|
|
|
|
|
Open and handle the error; |
|
|
|
|
A |
|
|
A stream or ranged read for big data |
Writing a config file in place |
Temp file, flush, |
Pitfalls checklist
| Mistake | Fix |
|---|---|
Using a synchronous call in a request handler |
Use the promise form; keep |
Starting a dependent call before the first has finished |
|
Leaving a |
|
Checking existence, then acting |
Act and handle |
Relative paths that depend on the working directory |
Anchor with |
Calling |
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 |
Streams; remember the 2 GiB limit. |
Joining untrusted names to a base path |
|
Assuming |
Use |
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 |
|
Fall back to copy and delete on |
Source: Node.js API: File system
References
-
Node.js Learn: Node.js File Paths, https://nodejs.org/learn/manipulating-files/nodejs-file-paths
-
Node.js Learn: Node.js file stats, https://nodejs.org/learn/manipulating-files/nodejs-file-stats
-
Node.js Learn: Reading files with Node.js, https://nodejs.org/learn/manipulating-files/reading-files-with-nodejs
-
Node.js Learn: Writing files with Node.js, https://nodejs.org/learn/manipulating-files/writing-files-with-nodejs
-
Node.js Learn: Working with file descriptors in Node.js, https://nodejs.org/learn/manipulating-files/working-with-file-descriptors-in-nodejs
-
Node.js Learn: Working with folders in Node.js, https://nodejs.org/learn/manipulating-files/working-with-folders-in-nodejs
-
Node.js Learn: How to work with different filesystems, https://nodejs.org/learn/manipulating-files/working-with-different-filesystems
-
Node.js Learn: Overview of Blocking vs Non-Blocking, https://nodejs.org/learn/asynchronous-work/overview-of-blocking-vs-non-blocking
-
Node.js Learn: Security Best Practices, https://nodejs.org/learn/getting-started/security-best-practices
-
Node.js API: File system, https://nodejs.org/docs/latest/api/fs.html
-
Node.js API: File system (fs.access), https://nodejs.org/docs/latest/api/fs.html#fsaccesspath-mode-callback
-
Node.js API: File system (fs.exists), https://nodejs.org/docs/latest/api/fs.html#fsexistspath-callback
-
Node.js API: Errors, https://nodejs.org/docs/latest/api/errors.html
-
Node.js API: Deprecated APIs, https://nodejs.org/docs/latest/api/deprecations.html
-
Node.js API: Permissions, https://nodejs.org/docs/latest/api/permissions.html
-
Node.js API: Process, https://nodejs.org/docs/latest/api/process.html
-
Node.js API: Buffer, https://nodejs.org/docs/latest/api/buffer.html
-
libuv: Design overview, https://docs.libuv.org/en/v1.x/design.html
-
libuv: Thread pool work scheduling, https://docs.libuv.org/en/v1.x/threadpool.html
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/