Skip to main content
Technology & EngineeringNodejs Patterns192 lines

File System

Modern fs/promises patterns for safe, efficient file system operations in Node.js

Quick Summary18 lines
You are an expert in Node.js `fs/promises` patterns for building safe, efficient file system operations.

## Key Points

- `fs/promises` — async, non-blocking, returns Promises. Use this by default.
- `fs` callbacks — legacy async API. Avoid in new code.
- `fs.*Sync` — blocks the event loop. Only acceptable at startup or in CLI scripts.
- Use atomic write (write to temp + rename) for any file that must not be partially written, such as config and state files.
- Always handle `ENOENT` explicitly — check `err.code` rather than pre-checking with `access()` to avoid TOCTOU races.
- Use streams (`fs.createReadStream`) for files larger than a few megabytes to avoid loading the full content into memory.
- Normalize and resolve paths with `path.resolve()` and `path.join()` — never concatenate path segments with string operations.
- Use `{ recursive: true }` with `mkdir` to make it idempotent and avoid checking existence first.
- **TOCTOU race conditions** — checking `access()` then `readFile()` is racy; another process can change the file in between. Instead, attempt the operation and handle the error.
- **Leaking file handles** — opening a file with `fs.open()` and not closing it on error exhausts the OS file descriptor limit.
- **Using synchronous methods in server code** — `readFileSync` blocks the entire event loop; a single slow disk read stalls all concurrent requests.
- **Ignoring encoding** — `readFile()` without an encoding argument returns a `Buffer`, not a string. This silently produces wrong results in string comparisons.
skilldb get nodejs-patterns-skills/File SystemFull skill: 192 lines
Paste into your CLAUDE.md or agent config

File System — Node.js Patterns

You are an expert in Node.js fs/promises patterns for building safe, efficient file system operations.

Core Philosophy

Overview

The node:fs/promises API provides a modern, promise-based interface to the file system. Combined with streams for large files and AbortController for cancellation, it replaces the legacy callback-based fs module in all new code. Correct usage requires understanding atomic writes, race conditions, permission handling, and the distinction between reading entire files versus streaming them.

Core Concepts

fs/promises vs fs (callback) vs fs (sync)

  • fs/promises — async, non-blocking, returns Promises. Use this by default.
  • fs callbacks — legacy async API. Avoid in new code.
  • fs.*Sync — blocks the event loop. Only acceptable at startup or in CLI scripts.

File Handles

fs.open() returns a FileHandle object that must be explicitly closed. Use try/finally or the using keyword to prevent leaks.

Watch and Polling

fs.watch() uses OS-native file system events (inotify, FSEvents, ReadDirectoryChangesW). It is efficient but has platform-specific quirks. fs.watchFile() uses polling and is more reliable for network mounts.

Implementation Patterns

Reading and writing files safely

const fs = require('node:fs/promises');
const path = require('node:path');

// Read JSON file
async function readJSON(filePath) {
  const content = await fs.readFile(filePath, 'utf-8');
  return JSON.parse(content);
}

// Atomic write — write to temp, then rename
async function writeFileAtomic(filePath, data) {
  const tmp = `${filePath}.${process.pid}.tmp`;
  try {
    await fs.writeFile(tmp, data, 'utf-8');
    await fs.rename(tmp, filePath); // atomic on same filesystem
  } catch (err) {
    await fs.unlink(tmp).catch(() => {});
    throw err;
  }
}

Directory traversal with recursion

const fs = require('node:fs/promises');
const path = require('node:path');

async function* walkDir(dir) {
  const entries = await fs.readdir(dir, { withFileTypes: true });
  for (const entry of entries) {
    const fullPath = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      yield* walkDir(fullPath);
    } else {
      yield fullPath;
    }
  }
}

// Usage
for await (const file of walkDir('/project/src')) {
  if (file.endsWith('.js')) {
    console.log(file);
  }
}

Recursive directory operations (Node.js 20+)

const fs = require('node:fs/promises');

// Recursive mkdir is idempotent
await fs.mkdir('/data/cache/images', { recursive: true });

// Recursive rm
await fs.rm('/tmp/build-output', { recursive: true, force: true });

// Recursive readdir
const allFiles = await fs.readdir('/project/src', { recursive: true });

Using FileHandle with explicit resource management

const fs = require('node:fs/promises');

async function appendLog(filePath, message) {
  const handle = await fs.open(filePath, 'a');
  try {
    await handle.appendFile(`${new Date().toISOString()} ${message}\n`);
  } finally {
    await handle.close();
  }
}

File watching with AbortController

const fs = require('node:fs/promises');

async function watchConfig(filePath, onChange) {
  const ac = new AbortController();

  try {
    const watcher = fs.watch(filePath, { signal: ac.signal });
    for await (const event of watcher) {
      if (event.eventType === 'change') {
        const content = await fs.readFile(filePath, 'utf-8');
        onChange(content);
      }
    }
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
  }

  return () => ac.abort(); // return a stop function
}

Checking file existence and permissions

const fs = require('node:fs/promises');
const { constants } = require('node:fs');

async function canReadWrite(filePath) {
  try {
    await fs.access(filePath, constants.R_OK | constants.W_OK);
    return true;
  } catch {
    return false;
  }
}

// Prefer stat when you also need metadata
async function getFileInfo(filePath) {
  try {
    const stat = await fs.stat(filePath);
    return { exists: true, size: stat.size, modified: stat.mtime };
  } catch (err) {
    if (err.code === 'ENOENT') return { exists: false };
    throw err;
  }
}

Best Practices

  • Use atomic write (write to temp + rename) for any file that must not be partially written, such as config and state files.
  • Always handle ENOENT explicitly — check err.code rather than pre-checking with access() to avoid TOCTOU races.
  • Use streams (fs.createReadStream) for files larger than a few megabytes to avoid loading the full content into memory.
  • Normalize and resolve paths with path.resolve() and path.join() — never concatenate path segments with string operations.
  • Use { recursive: true } with mkdir to make it idempotent and avoid checking existence first.

Common Pitfalls

  • TOCTOU race conditions — checking access() then readFile() is racy; another process can change the file in between. Instead, attempt the operation and handle the error.
  • Leaking file handles — opening a file with fs.open() and not closing it on error exhausts the OS file descriptor limit.
  • Using synchronous methods in server codereadFileSync blocks the entire event loop; a single slow disk read stalls all concurrent requests.
  • Ignoring encodingreadFile() without an encoding argument returns a Buffer, not a string. This silently produces wrong results in string comparisons.
  • Path traversal vulnerabilities — constructing file paths from user input without validation allows attackers to read arbitrary files. Always validate that resolved paths are within the expected directory.

Anti-Patterns

Over-engineering for hypothetical scale. Building for millions of users when you have hundreds adds complexity without value. Solve today's problems first.

Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide well wastes time and introduces unnecessary risk.

Premature abstraction. Creating elaborate frameworks and utilities before you have enough concrete cases to know what the abstraction should look like produces the wrong abstraction.

Neglecting error handling at boundaries. Internal code can trust its inputs, but system boundaries (user input, APIs, file I/O) require defensive validation.

Skipping documentation for obvious code. What is obvious to you today will not be obvious to your colleague next month or to you next year.

Install this skill directly: skilldb add nodejs-patterns-skills

Get CLI access →