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.

bash
# Verify Node is available
node --version

Output:

text
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.

APIImportStyleWhen to use
Callbackimport fs from 'node:fs'fs.readFile(path, cb)Legacy code; rare in new code
Synchronousimport fs from 'node:fs'fs.readFileSync(path)One-off CLI scripts, build tooling, top-of-file config loads
Promisesimport fs from 'node:fs/promises'await fs.readFile(path)Servers, libraries, anything that must not block the event loop
javascript
// 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.

javascript
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:

text
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.

javascript
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:

text
done

The flag option (matching POSIX open(2)) controls overwrite vs append vs exclusive create:

FlagBehaviour
'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
javascript
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.

javascript
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:

text
[ '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:

javascript
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:

text
[
  './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.

javascript
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:

text
{
  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:

javascript
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:

text
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.

javascript
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:

text
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.

javascript
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:

text
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.

javascript
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:

text
change: index.ts
rename: new-file.ts
change: components/Button.tsx

When to reach for which:

ToolUse when
fs.watchBuilt-in is enough; small project; one or two files
chokidarProduction tooling, glob filtering, cross-platform reliability
fs.watchFileNeed 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).

javascript
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:

text
/

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:

javascript
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:

text
{ port: 3000, host: '127.0.0.1' }

Node 20.11+ exposes import.meta.dirname and import.meta.filename directly, removing the boilerplate:

javascript
import { join } from 'node:path';

const configPath = join(import.meta.dirname, 'config.json');
console.log(configPath);

Output:

text
/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.

javascript
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:

text
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".

javascript
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:

text
readable

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.

javascript
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:

text
/usr/local/bin/node

Common pitfalls

  1. Using sync APIs in a serverreadFileSync blocks the entire event loop, including incoming requests. Use node:fs/promises everywhere except top-level config loads and CLI scripts.
  2. Forgetting to awaitwriteFile('x.txt', 'hi') without await schedules the write and returns immediately; the next line runs before the file exists. Either await it or chain .then().
  3. Race conditions with exists checks — Checking "does this file exist?" then opening it is racy. Just try to open it and handle ENOENT.
  4. Cross-filesystem rename failurerename fails with EXDEV if source and destination are on different filesystems. Fall back to cp + rm.
  5. Path strings on Windows — Hardcoding '/' works in Node on Windows for most reads, but breaks subprocesses. Always use path.join.
  6. Buffer vs string surprises — Without an encoding, readFile returns a Buffer, not a string. Comparing it to a string with === always returns false.
  7. Watcher events vary by platform — Don't trust eventType alone; use a debounce + stat to verify what actually changed.
  8. Not closing FileHandle — Leaks descriptors silently. Wrap in try/finally or use a using block (Node 22+ with explicit resource management).
  9. mkdir without recursive — Throws EEXIST if the directory already exists. Add { recursive: true } unless you specifically want exclusive create.
  10. Writing JSON without null, 2 — Defaults to a single-line blob. Pass the space argument (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.

javascript
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:

text
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.

javascript
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:

text
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:

javascript
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:

text
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.

javascript
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:

text
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.

javascript
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:

text
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:

javascript
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:

text
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.

javascript
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:

text
   142.7 MB  node_modules
     3.4 MB  src
     0.2 MB  public
     0.1 MB  scripts