skip to content

node:fs — File System

Node.js file system module — the three APIs (callback, sync, promises), reading and writing files, directory operations, watchers, atomic writes, and path module pairing.

14 min read 48 snippets deep dive

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.

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

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

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

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

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#

  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.

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