Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

File System Module (fs)

The fs module is one of the most useful built-in modules in Node.js. It allows you to work with the file system on your computer: reading files, creating files, updating files, deleting files, and renaming files. To use it, you must first require it:
const fs = require('fs');

Synchronous vs Asynchronous

Most methods in the fs module have both synchronous and asynchronous versions.
  • Asynchronous methods take a callback function as the last argument. They are non-blocking.
  • Synchronous methods block the execution until the operation completes. They usually end with Sync.
Why this matters: Imagine your server is a single checkout lane at a grocery store (the event loop). A synchronous file read is like one customer paying with a bag of pennies—every customer behind them waits. An asynchronous read is like taking the customer’s number and calling them back when the transaction clears, so the line keeps moving. Recommendation: Always use asynchronous methods in production to avoid blocking the event loop. Synchronous methods are acceptable only during startup (reading config files before the server begins accepting requests) or in CLI scripts where you are the only user.

fs API Style Comparison

Node.js offers three ways to do file operations. Here is how they compare:
StyleAPIError handlingBest for
Callbackfs.readFile(path, cb)Error-first callback (err, data)Legacy code, event-driven patterns
Synchronousfs.readFileSync(path)try/catchStartup scripts, CLI tools, one-off scripts
Promisefs.promises.readFile(path)async/await with try/catchModern production code, anything async
Decision framework: Use fs/promises by default in any new code. Use synchronous methods only in two situations: (1) reading config at process startup before the server starts accepting connections, or (2) CLI scripts where blocking is acceptable. Use callback-style only when integrating with legacy callback-based code that you cannot refactor.

Reading Files

Asynchronous Read

// readFile loads the ENTIRE file into memory at once.
// For small config files this is fine; for a 2GB log file, this will crash your process.
// For large files, use streams instead (covered in Chapter 06).
fs.readFile('example.txt', 'utf8', (err, data) => {
  if (err) {
    console.error(err);
    return;  // Always return after error handling to prevent executing the rest
  }
  console.log(data);
});
Note: If you don’t specify the encoding (‘utf8’), you will get a raw Buffer object instead of a string. This is a common source of confusion—you try to log the contents and see <Buffer 48 65 6c 6c 6f> instead of readable text.

Synchronous Read

try {
  const data = fs.readFileSync('example.txt', 'utf8');
  console.log(data);
} catch (err) {
  console.error(err);
}

Writing Files

Asynchronous Write

fs.writeFile() replaces the file and content if it exists. If the file doesn’t exist, a new file, containing the specified content, will be created.
const content = 'Hello, this is new content!';

fs.writeFile('output.txt', content, err => {
  if (err) {
    console.error(err);
    return;
  }
  console.log('File written successfully');
});

Appending to Files

To add content to the end of a file without replacing it, use fs.appendFile().
fs.appendFile('output.txt', '\nThis is appended text.', err => {
  if (err) throw err;
  console.log('Content appended!');
});

Directories

Creating a Directory

if (!fs.existsSync('./new-folder')) {
  fs.mkdir('./new-folder', err => {
    if (err) throw err;
    console.log('Directory created');
  });
}

Reading a Directory

fs.readdir('./', (err, files) => {
  if (err) console.log(err);
  else console.log(files); // Returns an array of filenames
});

Removing Files and Directories

// Remove a file
fs.unlink('./output.txt', (err) => {
  if (err) throw err;
  console.log('File deleted');
});

// Remove an empty directory
fs.rmdir('./empty-folder', (err) => {
  if (err) throw err;
  console.log('Directory removed');
});

// Remove directory recursively (Node.js 14.14+)
fs.rm('./folder-with-files', { recursive: true, force: true }, (err) => {
  if (err) throw err;
  console.log('Directory removed recursively');
});

Promise-based API (fs/promises)

Modern Node.js provides a promise-based API that works beautifully with async/await.
const fs = require('fs/promises');  // Note: import from 'fs/promises', not 'fs'
const path = require('path');

async function fileOperations() {
  try {
    // With fs/promises, every operation returns a Promise.
    // This lets us use async/await instead of nested callbacks,
    // making the code read top-to-bottom like synchronous code
    // while remaining non-blocking under the hood.
    
    // Read file
    const content = await fs.readFile('input.txt', 'utf8');
    console.log(content);

    // Write file
    await fs.writeFile('output.txt', 'New content');

    // Append to file
    await fs.appendFile('output.txt', '\nMore content');

    // Get file stats
    const stats = await fs.stat('output.txt');
    console.log('File size:', stats.size);
    console.log('Is file:', stats.isFile());
    console.log('Is directory:', stats.isDirectory());
    console.log('Created:', stats.birthtime);
    console.log('Modified:', stats.mtime);

    // Rename file
    await fs.rename('output.txt', 'renamed.txt');

    // Copy file
    await fs.copyFile('renamed.txt', 'backup.txt');

    // Create directory
    await fs.mkdir('./new-folder', { recursive: true });

    // Read directory
    const files = await fs.readdir('./');
    console.log('Files:', files);

  } catch (error) {
    console.error('Error:', error.message);
  }
}

fileOperations();
Always use fs/promises with async/await for cleaner, more maintainable code in modern Node.js applications.

Watching Files for Changes

const fs = require('fs');

// Watch a file for changes
fs.watch('config.json', (eventType, filename) => {
  console.log(`Event: ${eventType}, File: ${filename}`);
  if (eventType === 'change') {
    console.log('Config file was modified!');
    // Reload configuration
  }
});

// Watch a directory
fs.watch('./src', { recursive: true }, (eventType, filename) => {
  console.log(`${eventType}: ${filename}`);
});

Working with JSON Files

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

// Read and parse JSON
async function readJSON(filepath) {
  const data = await fs.readFile(filepath, 'utf8');
  return JSON.parse(data);
}

// Write JSON with formatting
async function writeJSON(filepath, data) {
  const json = JSON.stringify(data, null, 2); // Pretty print with 2 spaces
  await fs.writeFile(filepath, json);
}

// Usage
async function main() {
  // Read existing data
  const config = await readJSON('./config.json');
  console.log('Current config:', config);

  // Modify and save
  config.updatedAt = new Date().toISOString();
  await writeJSON('./config.json', config);
}

Practical Example: File-based Logger

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

class FileLogger {
  constructor(logDir = './logs') {
    this.logDir = logDir;
    this.init();
  }

  async init() {
    // Ensure log directory exists
    await fs.mkdir(this.logDir, { recursive: true });
  }

  getLogFileName() {
    const date = new Date().toISOString().split('T')[0];
    return path.join(this.logDir, `app-${date}.log`);
  }

  formatMessage(level, message) {
    const timestamp = new Date().toISOString();
    return `[${timestamp}] [${level.toUpperCase()}] ${message}\n`;
  }

  async log(level, message) {
    const logFile = this.getLogFileName();
    const formatted = this.formatMessage(level, message);
    await fs.appendFile(logFile, formatted);
  }

  async info(message) {
    await this.log('info', message);
  }

  async error(message) {
    await this.log('error', message);
  }

  async warn(message) {
    await this.log('warn', message);
  }

  // Get recent logs
  async getRecentLogs(lines = 50) {
    const logFile = this.getLogFileName();
    try {
      const content = await fs.readFile(logFile, 'utf8');
      const logLines = content.trim().split('\n');
      return logLines.slice(-lines);
    } catch (err) {
      return [];
    }
  }

  // Clean old logs
  async cleanOldLogs(daysToKeep = 30) {
    const files = await fs.readdir(this.logDir);
    const cutoffDate = new Date();
    cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);

    for (const file of files) {
      const filePath = path.join(this.logDir, file);
      const stats = await fs.stat(filePath);
      
      if (stats.mtime < cutoffDate) {
        await fs.unlink(filePath);
        console.log(`Deleted old log: ${file}`);
      }
    }
  }
}

module.exports = FileLogger;

Performance: readFile vs Streams vs readline

Choosing the right file-reading approach depends on file size and what you need to do with the data:
ApproachMemory usageSpeedUse when
fs.readFile()Entire file in RAMFast for small filesFile is under ~50MB, you need it all at once
fs.createReadStream()~64KB buffer (configurable)Constant memory regardless of file sizeFile is large or you are processing chunks (compression, upload)
readline interfaceOne line in memory at a timeSlightly slower due to line parsing overheadProcessing text line-by-line (log parsing, CSV)
fs.read() (low-level)You control buffer size exactlyMaximum control, most codeBinary file formats, random access, seek operations
Benchmark intuition (rough numbers on modern hardware):
  • readFileSync on a 1MB file: ~2ms
  • readFileSync on a 100MB file: ~80ms (and blocks the event loop the entire time)
  • createReadStream on a 100MB file: ~80ms total, but the event loop stays responsive throughout
  • readFileSync on a 1GB file: ~800ms of blocked event loop — every request to your server waits
Edge case — reading the same file concurrently: If 100 requests all call readFile on the same 10MB file simultaneously, you will use ~1GB of RAM (100 copies of the file). Node.js does not deduplicate concurrent reads of the same file. For frequently-accessed files, read once at startup and cache the result, or use a stream that pipes directly to the response.

Common Pitfalls with File Operations

Race conditions with fs.existsSync: A common anti-pattern is checking if a file exists and then operating on it. Between your check and your operation, another process could delete or modify the file. Instead, just perform the operation and handle the error:
// Anti-pattern -- race condition between check and operation
if (fs.existsSync('file.txt')) {
  fs.unlinkSync('file.txt');  // File might be gone by now!
}

// Better -- just try and handle the error
try {
  await fs.unlink('file.txt');
} catch (err) {
  if (err.code !== 'ENOENT') throw err;  // Ignore "file not found", rethrow others
}

Common fs Error Codes

When file operations fail, the error object includes a code property. Knowing these codes lets you handle failures precisely:
Error codeMeaningTypical causeHow to handle
ENOENTNo such file or directoryPath is wrong, file was deletedCreate the file/directory, or return 404
EACCESPermission deniedProcess lacks read/write permissionsCheck file permissions, run with correct user
EEXISTFile already existsTrying to create something that already existsUse { recursive: true } for mkdir, or catch and continue
EISDIRIs a directoryTried to read/write a directory as a fileCheck path, use readdir instead
EMFILEToo many open filesExhausted file descriptor limitUse streams with backpressure, increase ulimit
ENOSPCNo space left on deviceDisk is fullAlert ops, clean up, use disk space monitoring
EPERMOperation not permittedOS-level restriction (e.g., deleting a root file on Windows)Run with elevated permissions or change approach
Edge case — EMFILE in production: The default file descriptor limit on many Linux systems is 1024. A busy server that opens files for logging, uploads, and static assets can hit this limit. Symptoms: random “EMFILE: too many open files” errors that seem intermittent because they depend on how many files are open at that moment. Fix: increase the limit with ulimit -n 65536 in your startup script, and ensure you always close file handles (streams auto-close on end/error, but manual fs.open calls need explicit fs.close).

Summary

  • Use asynchronous methods in production (avoid Sync methods except at startup)
  • Prefer fs/promises with async/await for modern code—it eliminates callback nesting
  • Use fs.watch() to monitor file/directory changes (but note it is platform-dependent and can fire duplicate events)
  • fs.stat() provides file metadata (size, dates, type)
  • Always handle errors—file not found (ENOENT), permission denied (EACCES), and disk full (ENOSPC) are the most common
  • For large files, use Streams instead of readFile (covered in detail in Chapter 06)
  • Use { recursive: true } with fs.mkdir to safely create nested directories without checking existence first