jsguides

fs module

The fs module is a core Node.js module that provides an API for interacting with the file system. It supports both synchronous and asynchronous operations, as well as promise‑based variants via fs/promises. Whether you need to read a configuration file, write logs, or recursively scan directories, fs is the go‑to module.

Syntax

const fs = require('fs');               // CommonJS
import fs from 'fs';                    // ES modules
import { readFile } from 'fs/promises'; // promise‑based

Parameters

The fs module exports dozens of functions; the most common ones share similar parameter patterns.

ParameterTypeDefaultDescription
pathstring(required)File or directory path. Can be absolute or relative to process.cwd().
optionsobject or stringnullEncoding (e.g., 'utf8') or an object with encoding, flag, etc.
callbackfunction(required for async)Called with (err, data) when the operation completes.
modeinteger0o666 (files) / 0o777 (dirs)Permissions for newly created files/directories (octal).

Examples

Basic usage

Read a text file asynchronously with fs.readFile:

const fs = require('fs');

fs.readFile('notes.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Error reading file:', err);
    return;
  }
  console.log('File content:', data);
});

readFile loads the entire file into memory before calling the callback. For small text files like configuration or logs, this is fine — the data arrives as a single string. The callback also handles errors, so a missing file does not crash your process.

Output (assuming notes.txt contains "Hello from Node.js"):

File content: Hello from Node.js

The callback receives the file content as a string when you pass an encoding like 'utf8'. Without an encoding, the callback gets a Buffer instead, which you can convert to a string manually with data.toString(). If the file does not exist or cannot be read, the err parameter carries the error and data is undefined.

Write a file synchronously

Use fs.writeFileSync for simple, blocking writes:

const fs = require('fs');

try {
  fs.writeFileSync('output.log', 'Log entry at ' + new Date().toISOString());
  console.log('File written successfully');
} catch (err) {
  console.error('Write failed:', err);
}

The write completes before the next line executes — writeFileSync is fully blocking. When the operation succeeds, the script logs a confirmation message. If the directory or file is not writable, the error is caught and written to stderr instead of crashing the process.

Output:

File written successfully

writeFileSync overwrites the file if it already exists and creates it if it does not. If the directory path does not exist, the call throws. This is fine for scripts that run sequentially, but reading and writing individual files is only part of the picture — working with directories requires a different set of functions.

Working with directories

List the contents of a directory with fs.readdir:

const fs = require('fs');
const path = require('path');

const target = './src';

fs.readdir(target, (err, entries) => {
  if (err) {
    console.error('Cannot read directory:', err);
    return;
  }
  console.log(`Files in ${target}:`);
  entries.forEach(entry => {
    const fullPath = path.join(target, entry);
    const stat = fs.statSync(fullPath);
    console.log(`  ${entry} (${stat.isDirectory() ? 'dir' : 'file'})`);
  });
});

readdir works asynchronously, so it does not block the event loop while the OS looks up the directory contents. The callback receives an array of entry names and an optional error. To determine whether each entry is a file or a subdirectory, you must call fs.statSync or fs.stat separately on each name — the directory listing alone does not include type information.

Example output:

Files in ./src:
  index.js (file)
  utils (dir)
  config.json (file)

readdir returns entry names only — it does not tell you whether each entry is a file or a directory. To get that information, you call fs.statSync() or fs.stat() on each entry, as shown above. This matters when you need to recurse into subdirectories or filter out certain file types before processing.

Common Patterns

Copy a file using streams

For large files, use streams to avoid loading the entire file into memory:

const fs = require('fs');

function copyFile(source, destination) {
  const readStream = fs.createReadStream(source);
  const writeStream = fs.createWriteStream(destination);

  readStream.pipe(writeStream);

  writeStream.on('finish', () => {
    console.log(`Copied ${source} → ${destination}`);
  });

  readStream.on('error', (err) => {
    console.error('Read error:', err);
  });

  writeStream.on('error', (err) => {
    console.error('Write error:', err);
  });
}

copyFile('input.mp4', 'output.mp4');

Streaming is ideal for large files because it never holds the entire file in memory — data flows from the read stream through the pipe to the write stream in chunks. But streaming is not the right tool for every task. When you need to process a whole directory tree, recursion with synchronous methods is often simpler to reason about.

Recursive directory removal

Node.js ≥ 14 provides fs.rmSync with the recursive option; for older versions you can write a small recursive function.

const fs = require('fs');
const path = require('path');

function rmRecursive(dir) {
  if (fs.existsSync(dir)) {
    fs.readdirSync(dir).forEach(entry => {
      const full = path.join(dir, entry);
      if (fs.lstatSync(full).isDirectory()) {
        rmRecursive(full);
      } else {
        fs.unlinkSync(full);
      }
    });
    fs.rmdirSync(dir);
  }
}

rmRecursive('./old‑cache');
console.log('Removed ./old‑cache');

The recursive approach walks the directory tree manually, removing files first and then empty directories from the bottom up. For Node.js ≥ 14, fs.rmSync('./old-cache', { recursive: true, force: true }) does the same thing in a single call. Either way, every fs operation you write lives in one of three execution styles, and choosing the right one directly affects how responsive your application stays under load.

Sync vs async vs promise-based

The fs module offers three styles for every operation, and choosing the right one matters for application responsiveness.

Synchronous methods (e.g., readFileSync) block the event loop until the operation completes. They are fine for startup scripts, CLI tools, and code that runs before the server begins accepting requests — but never use them inside request handlers or anywhere that needs to stay responsive.

Callback-based async methods (e.g., readFile) do not block. They return immediately and call the callback when done. This style is verbose with nested callbacks but works in all Node.js versions.

Promise-based methods via fs/promises are the modern choice. They integrate naturally with async/await and avoid callback nesting:

import { readFile, writeFile } from 'fs/promises';

async function updateConfig(path, newData) {
  const existing = await readFile(path, 'utf8');
  const merged = JSON.stringify({ ...JSON.parse(existing), ...newData });
  await writeFile(path, merged, 'utf8');
}

Use fs/promises for all new code. Fall back to callback-style only when targeting Node.js versions below 10. Beyond reading and writing, the fs module also lets you monitor the file system for changes — useful for live-reload tools, log tailing, and configuration hot-reload workflows.

Watching for file changes

fs.watch() monitors a file or directory for changes and fires a callback when a change is detected:

const fs = require('fs');

const watcher = fs.watch('./config.json', (eventType, filename) => {
  console.log(`${filename} changed: ${eventType}`);
});

// Stop watching when done
watcher.close();

The eventType is either 'rename' (file created, moved, or deleted) or 'change' (file content changed). On macOS, fs.watch() uses kqueue under the hood and may miss rapid consecutive changes — for production file watching, a library like chokidar handles edge cases more reliably.

See Also

  • Stream — Stream-based file processing
  • Path — Path manipulation utilities
  • FsPromises — Promise-based FS API