cheat sheet
node:fs
Node.js file system module — the three APIs (callback, sync, promises), reading and writing files, directory operations, watchers, atomic writes, and path module pairing.
node:fs — File System
What it is
node:fs is the built-in Node.js module for interacting with the file system — reading, writing, copying, watching, and inspecting files and directories. It ships with three sibling APIs (callback, synchronous, and Promise-based) so the same operation can be performed in whichever style fits the call site, and is the foundation that nearly every higher-level Node tool (bundlers, test runners, frameworks) reaches for under the hood. For modern code, prefer node:fs/promises — its async/await ergonomics are dramatically nicer than the legacy callback or sync variants.
Install
node:fs is built into Node.js — no install step required. The node: prefix has been available since Node 12 and is the recommended way to import any built-in to avoid shadowing by an npm package with the same name.
# Verify Node is available
node --version
Output:
v22.14.0
The three APIs
Node exposes three parallel surfaces for the same file system operations. Understanding which is which prevents accidental event-loop blocking and makes refactoring between them trivial.
| API | Import | Style | When to use |
|---|---|---|---|
| Callback | import fs from 'node:fs' | fs.readFile(path, cb) | Legacy code; rare in new code |
| Synchronous | import fs from 'node:fs' | fs.readFileSync(path) | One-off CLI scripts, build tooling, top-of-file config loads |
| Promises | import fs from 'node:fs/promises' | await fs.readFile(path) | Servers, libraries, anything that must not block the event loop |
// Callback (legacy)
import { readFile } from 'node:fs';
readFile('config.json', 'utf8', (err, data) => {
if (err) throw err;
console.log(data);
});
// Sync (blocks the event loop — fine for CLIs)
import { readFileSync } from 'node:fs';
const data = readFileSync('config.json', 'utf8');
// Promises (preferred)
import { readFile } from 'node:fs/promises';
const data = await readFile('config.json', 'utf8');
Output: (none — exits 0 on success)
Reading files
readFile loads an entire file into memory. The second argument is either an encoding string ('utf8', 'ascii', 'base64') or an options object. Without an encoding, the call returns a Buffer; with one, it returns a string.
import { readFile } from 'node:fs/promises';
// As a string
const text = await readFile('notes.md', 'utf8');
console.log(text.split('\n').length, 'lines');
// As a Buffer (binary)
const buf = await readFile('icon.png');
console.log(buf.length, 'bytes');
// With explicit options object
const json = await readFile('package.json', { encoding: 'utf8', flag: 'r' });
Output:
42 lines
8742 bytes
For large files (anything you wouldn't comfortably hold in RAM), use node:fs/promises createReadStream or the streams module instead — covered in node-streams.
Writing files
writeFile overwrites the destination file (or creates it). The second argument is the contents — a string, a Buffer, a Uint8Array, or anything that implements the async iterator protocol.
import { writeFile, appendFile } from 'node:fs/promises';
// String contents
await writeFile('out.txt', 'hello world\n', 'utf8');
// Buffer contents
await writeFile('data.bin', Buffer.from([0x48, 0x49]));
// JSON helper (no built-in, but a one-liner)
await writeFile('config.json', JSON.stringify({ port: 3000 }, null, 2));
// Append instead of overwrite
await appendFile('log.txt', `${new Date().toISOString()} startup\n`);
console.log('done');
Output:
done
The flag option (matching POSIX open(2)) controls overwrite vs append vs exclusive create:
| Flag | Behaviour |
|---|---|
'w' (default) | Truncate or create |
'a' | Append; create if missing |
'wx' | Exclusive create — fails with EEXIST if file already exists |
'r+' | Read + write; fails if missing |
'a+' | Read + append; create if missing |
import { writeFile } from 'node:fs/promises';
// Fails if the file already exists — useful for "create lockfile"
try {
await writeFile('app.lock', String(process.pid), { flag: 'wx' });
} catch (err) {
if (err.code === 'EEXIST') {
console.error('Another instance is already running');
process.exit(1);
}
throw err;
}
Output: (none — exits 0 on success)
Listing directories
readdir returns the names of entries in a directory. With { withFileTypes: true } it returns Dirent objects that carry the file type without an extra stat call — the fast path when you need to skip subdirectories or symlinks.
import { readdir } from 'node:fs/promises';
// Bare names
const names = await readdir('./src');
console.log(names);
// Dirent objects (file type included)
const entries = await readdir('./src', { withFileTypes: true });
for (const entry of entries) {
const kind = entry.isDirectory() ? 'dir ' : entry.isFile() ? 'file' : '????';
console.log(`${kind} ${entry.name}`);
}
Output:
[ 'components', 'lib', 'pages', 'index.ts' ]
dir components
dir lib
dir pages
file index.ts
For recursive listing, pass { recursive: true } (Node 20+) — but the result is a flat list of relative paths, not a tree:
import { readdir } from 'node:fs/promises';
const all = await readdir('./src', { recursive: true, withFileTypes: true });
const files = all
.filter((d) => d.isFile())
.map((d) => `${d.parentPath}/${d.name}`);
console.log(files.slice(0, 3));
Output:
[
'./src/index.ts',
'./src/components/Button.tsx',
'./src/lib/util.ts'
]
Inspecting files with stat
stat (follow symlinks) and lstat (don't follow) return a Stats object describing size, type, modification time, and POSIX mode bits. The boolean accessors (isFile(), isDirectory(), isSymbolicLink()) read cleaner than poking at mode directly.
import { stat } from 'node:fs/promises';
const s = await stat('./package.json');
console.log({
size: s.size,
isFile: s.isFile(),
isDir: s.isDirectory(),
mtime: s.mtime.toISOString(),
mode: '0' + (s.mode & 0o777).toString(8),
});
Output:
{
size: 1247,
isFile: true,
isDir: false,
mtime: '2026-05-21T10:42:18.000Z',
mode: '0644'
}
For a "does this path exist?" check, prefer stat over the deprecated exists API and handle the ENOENT error explicitly — it makes the intent clear and avoids race conditions:
import { stat } from 'node:fs/promises';
async function exists(path) {
try {
await stat(path);
return true;
} catch (err) {
if (err.code === 'ENOENT') return false;
throw err;
}
}
console.log(await exists('./package.json'));
console.log(await exists('./missing.txt'));
Output:
true
false
Creating and removing directories
mkdir creates a single directory by default; passing { recursive: true } makes it behave like mkdir -p — it creates any missing parent components and silently succeeds if the directory already exists.
import { mkdir, rm } from 'node:fs/promises';
// Create nested directories (like `mkdir -p`)
await mkdir('build/cache/assets', { recursive: true });
// Remove a directory tree (like `rm -rf`)
await rm('build', { recursive: true, force: true });
// Remove a single file — `unlink` works too, but `rm` is more consistent
await rm('temp.log', { force: true });
console.log('cleaned up');
Output:
cleaned up
The force: true flag silences ENOENT errors so removing a non-existent path is a no-op — equivalent to rm -f. Without it, rm throws if the path doesn't exist.
Copying and renaming
cp (Node 16.7+ stable in 20) recursively copies files and directories — the equivalent of cp -r. rename is the cheap in-place move; on the same filesystem it's effectively free (an inode update), but it fails across filesystems with EXDEV.
import { cp, rename, copyFile } from 'node:fs/promises';
// Copy a single file
await copyFile('source.txt', 'backup.txt');
// Copy a directory tree
await cp('./src', './build', { recursive: true });
// Copy with a filter — skip node_modules and dotfiles
await cp('./src', './dist', {
recursive: true,
filter: (src) => !src.includes('node_modules') && !/\/\./.test(src),
});
// Atomic rename (same filesystem only)
await rename('out.tmp', 'out.txt');
console.log('copied and renamed');
Output:
copied and renamed
Watching for changes
fs.watch reports changes to files and directories. It's lightweight and built in, but the events it emits vary by platform — macOS in particular collapses related events, and renames are sometimes reported as change. For production-grade watching, reach for chokidar, which normalises platform quirks and adds glob filtering.
import { watch } from 'node:fs/promises';
const ac = new AbortController();
setTimeout(() => ac.abort(), 30_000);
try {
for await (const event of watch('./src', { recursive: true, signal: ac.signal })) {
console.log(`${event.eventType}: ${event.filename}`);
}
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
Output:
change: index.ts
rename: new-file.ts
change: components/Button.tsx
When to reach for which:
| Tool | Use when |
|---|---|
fs.watch | Built-in is enough; small project; one or two files |
chokidar | Production tooling, glob filtering, cross-platform reliability |
fs.watchFile | Need polling on a network filesystem where fs.watch is unreliable |
The path module — always pair with fs
node:path builds, parses, and normalises path strings without ever touching the filesystem. Use it instead of string concatenation so the same code works on Windows (backslash) and POSIX (forward slash).
import path from 'node:path';
path.join('/home/alice', 'docs', 'file.txt');
// → '/home/alice/docs/file.txt'
path.resolve('src', 'index.js');
// → '/current/working/dir/src/index.js' (absolute)
path.dirname('/home/alice/docs/file.txt'); // → '/home/alice/docs'
path.basename('/home/alice/docs/file.txt'); // → 'file.txt'
path.basename('/home/alice/docs/file.txt', '.txt'); // → 'file'
path.extname('script.min.js'); // → '.js'
path.parse('/home/alice/file.txt');
// → { root: '/', dir: '/home/alice', base: 'file.txt', ext: '.txt', name: 'file' }
// Always use path.sep instead of hardcoding '/' or '\\'
console.log(path.sep);
Output:
/
path.join collapses redundant separators and resolves .. segments. path.resolve is similar but treats the result as absolute — it walks right-to-left until it finds an absolute path, then resolves from there.
ESM paths — replacing __dirname
In ESM, __dirname and __filename are not defined. The portable replacements use import.meta.url plus node:url helpers:
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { readFile } from 'node:fs/promises';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const configPath = join(__dirname, 'config.json');
const config = JSON.parse(await readFile(configPath, 'utf8'));
console.log(config);
Output:
{ port: 3000, host: '127.0.0.1' }
Node 20.11+ exposes import.meta.dirname and import.meta.filename directly, removing the boilerplate:
import { join } from 'node:path';
const configPath = join(import.meta.dirname, 'config.json');
console.log(configPath);
Output:
/home/alice/app/config.json
File handles for advanced operations
fs.open returns a FileHandle — an object representing an open file descriptor. Use it when you need partial reads, partial writes, or repeated I/O on the same file without re-opening it. Always close it in a finally block to avoid leaking descriptors.
import { open } from 'node:fs/promises';
const handle = await open('large.bin', 'r');
try {
// Read 16 bytes starting at offset 0
const buf = Buffer.alloc(16);
const { bytesRead } = await handle.read(buf, 0, 16, 0);
console.log(`read ${bytesRead} bytes: ${buf.toString('hex')}`);
} finally {
await handle.close();
}
Output:
read 16 bytes: 89504e470d0a1a0a0000000d49484452
A FileHandle can also produce a ReadStream or WriteStream via createReadStream() / createWriteStream() — useful for piping into and out of the streams pipeline.
Permissions and ownership
chmod changes POSIX permission bits, chown changes owner UID and group GID. Both no-op on Windows (which uses ACLs instead). Use octal literals for clarity — 0o644 reads as "owner rw, group r, world r".
import { chmod, access, constants } from 'node:fs/promises';
// Make a script executable
await chmod('./bin/run.sh', 0o755);
// Check if the current process can read a file
try {
await access('./secrets.env', constants.R_OK);
console.log('readable');
} catch {
console.log('not readable');
}
// Combine flags — readable AND writable
await access('./out.log', constants.R_OK | constants.W_OK);
Output:
readable
Symbolic and hard links
symlink creates a symlink (a path that points to another path), link creates a hard link (a second name for the same inode). On Windows, creating symlinks requires elevated privileges by default.
import { symlink, link, readlink, unlink } from 'node:fs/promises';
// Create a symlink — pointer to a target path
await symlink('/usr/local/bin/node', './node-current');
// Read where a symlink points
console.log(await readlink('./node-current'));
// Hard link — two filenames for the same inode
await link('original.txt', 'alias.txt');
// Remove a symlink (do NOT use rm; that may follow the link)
await unlink('./node-current');
Output:
/usr/local/bin/node
Common pitfalls
- Using sync APIs in a server —
readFileSyncblocks the entire event loop, including incoming requests. Usenode:fs/promiseseverywhere except top-level config loads and CLI scripts. - Forgetting to await —
writeFile('x.txt', 'hi')withoutawaitschedules the write and returns immediately; the next line runs before the file exists. Eitherawaitit or chain.then(). - Race conditions with
existschecks — Checking "does this file exist?" then opening it is racy. Just try to open it and handleENOENT. - Cross-filesystem rename failure —
renamefails withEXDEVif source and destination are on different filesystems. Fall back tocp+rm. - Path strings on Windows — Hardcoding
'/'works in Node on Windows for most reads, but breaks subprocesses. Always usepath.join. Buffervs string surprises — Without an encoding,readFilereturns aBuffer, not a string. Comparing it to a string with===always returnsfalse.- Watcher events vary by platform — Don't trust
eventTypealone; use a debounce +statto verify what actually changed. - Not closing
FileHandle— Leaks descriptors silently. Wrap intry/finallyor use ausingblock (Node 22+ with explicit resource management). mkdirwithoutrecursive— ThrowsEEXISTif the directory already exists. Add{ recursive: true }unless you specifically want exclusive create.- Writing JSON without
null, 2— Defaults to a single-line blob. Pass thespaceargument (JSON.stringify(x, null, 2)) for human-readable output.
Real-world recipes
Atomic file write
Writing directly to the final path leaves a half-written file if the process crashes mid-write. The atomic pattern writes to a sibling temp file and renames into place — rename is atomic on the same filesystem, so readers either see the old contents or the complete new contents, never a partial mix.
import { writeFile, rename } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
async function atomicWrite(path, contents) {
const tmp = `${path}.${randomBytes(6).toString('hex')}.tmp`;
await writeFile(tmp, contents);
await rename(tmp, path);
}
await atomicWrite('./state.json', JSON.stringify({ count: 1 }, null, 2));
console.log('wrote atomically');
Output:
wrote atomically
Recursive directory copy with filter
Copy a project's src/ to dist/ while skipping .test.ts files and any __snapshots__ directories — the filter callback gets both source and destination paths and is invoked for every entry.
import { cp } from 'node:fs/promises';
await cp('./src', './dist', {
recursive: true,
filter: (src) => {
if (src.endsWith('.test.ts')) return false;
if (src.includes('__snapshots__')) return false;
return true;
},
});
console.log('copied src → dist');
Output:
copied src → dist
Walk a directory tree manually
When { recursive: true } isn't expressive enough (e.g. you want depth-first traversal with early bailout), recurse manually using withFileTypes to skip an extra stat per entry:
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
async function* walk(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules') continue;
yield* walk(full);
} else if (entry.isFile()) {
yield full;
}
}
}
let count = 0;
for await (const file of walk('./src')) {
if (file.endsWith('.ts')) count++;
}
console.log(`${count} TypeScript files`);
Output:
47 TypeScript files
Tail a log file
Open a file, read from the current end, then watch for appends and emit new content as it arrives. Useful for "follow" mode in CLI tools.
import { open, watch } from 'node:fs/promises';
const path = './app.log';
const handle = await open(path, 'r');
let offset = (await handle.stat()).size;
console.log('following', path);
for await (const event of watch(path)) {
if (event.eventType !== 'change') continue;
const { size } = await handle.stat();
if (size > offset) {
const buf = Buffer.alloc(size - offset);
await handle.read(buf, 0, buf.length, offset);
process.stdout.write(buf.toString('utf8'));
offset = size;
}
}
Output:
following ./app.log
2026-05-25T10:00:01Z request /api/health 200
2026-05-25T10:00:03Z request /api/users 200
Lockfile pattern
Use wx (exclusive create) to coordinate single-instance CLIs — if another copy is already running, the second one fails fast instead of corrupting shared state.
import { writeFile, unlink } from 'node:fs/promises';
const LOCK = './app.lock';
try {
await writeFile(LOCK, String(process.pid), { flag: 'wx' });
} catch (err) {
if (err.code === 'EEXIST') {
console.error('Another instance is running. Exiting.');
process.exit(1);
}
throw err;
}
process.on('exit', () => {
try { unlink(LOCK); } catch {}
});
console.log('acquired lock, doing work…');
Output:
acquired lock, doing work…
Read-modify-write a JSON config
A common config-edit pattern, written safely with atomic write so a crash mid-write can't corrupt the file:
import { readFile, writeFile, rename } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
async function updateJson(path, mutate) {
const text = await readFile(path, 'utf8');
const obj = JSON.parse(text);
mutate(obj);
const tmp = `${path}.${randomBytes(6).toString('hex')}.tmp`;
await writeFile(tmp, JSON.stringify(obj, null, 2) + '\n');
await rename(tmp, path);
}
await updateJson('./package.json', (pkg) => {
pkg.scripts ??= {};
pkg.scripts.lint = 'eslint .';
});
console.log('package.json updated');
Output:
package.json updated
Disk-usage summary
Walk a tree and sum file sizes per top-level subdirectory — useful for a one-off "what's eating disk?" report.
import { readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
async function dirSize(dir) {
let total = 0;
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) total += await dirSize(full);
else if (entry.isFile()) total += (await stat(full)).size;
}
return total;
}
const root = './';
for (const entry of await readdir(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const bytes = await dirSize(join(root, entry.name));
console.log(`${(bytes / 1e6).toFixed(1).padStart(8)} MB ${entry.name}`);
}
Output:
142.7 MB node_modules
3.4 MB src
0.2 MB public
0.1 MB scripts