File System

beginner nodejs fs io promises

The fs module is how we touch the disk from Node — read files, write files, list directories, watch for changes. It’s one of the first modules everyone uses, and getting the async/sync distinction right is important because Node runs on a single thread.

In simple language: when we read a file synchronously, the entire Node process stops until the file is read. That’s fine for a tiny config at startup, but disastrous inside a request handler — every other request waits.

The three flavors

Node gives us the same operations in three styles. They all do the same thing, just with different async patterns.

fs (callbacks)
Original API. Error-first callback. Old-school.
fs.readFile(path, cb)
fs/promises
Modern. Works with async/await. Use this.
await fs.readFile(path)
fs.*Sync
Blocks the event loop. Only at startup.
fs.readFileSync(path)

Reading and writing — the modern way

We almost always reach for fs/promises. Here’s the pattern we use 90% of the time.

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

// read JSON config
const raw = await readFile('./config.json', 'utf8');
const config = JSON.parse(raw);

// write JSON back
await writeFile('./config.json', JSON.stringify(config, null, 2));

Notice the 'utf8' — without it, readFile returns a Buffer (raw bytes). Forgetting this is the #1 fs gotcha.

When sync is actually okay

There’s exactly one place sync APIs are fine: at startup, before the server is accepting traffic. Loading a config, checking if a directory exists — fine.

import { existsSync, mkdirSync } from 'node:fs';

if (!existsSync('./logs')) {
  mkdirSync('./logs', { recursive: true });
}

Inside a request handler? Never. We block every other in-flight request.

Appending to a log file

A super common real-world pattern. appendFile creates the file if it doesn’t exist.

import { appendFile } from 'node:fs/promises';

async function logEvent(event) {
  const line = `${new Date().toISOString()} ${JSON.stringify(event)}\n`;
  await appendFile('./logs/app.log', line);
}

For high-volume logging we’d use a write stream instead — opening/closing the file on every line is slow.

Watching files

fs.watch notifies us when a file or directory changes. Great for dev tools, config hot-reload, etc. The only catch: it’s a bit unreliable across platforms (macOS uses FSEvents, Linux uses inotify, Windows is its own beast). For production-grade watching, libraries like chokidar smooth out the differences.

import { watch } from 'node:fs';

watch('./config.json', (eventType, filename) => {
  console.log(`${filename} changed (${eventType})`);
  // reload config here
});

Reading large files — use streams

readFile loads the entire file into memory. For a 10GB log file? RIP. Stream it instead.

import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

const rl = createInterface({
  input: createReadStream('./huge.log'),
  crlfDelay: Infinity,
});

for await (const line of rl) {
  if (line.includes('ERROR')) console.log(line);
}

Streams process the file chunk by chunk — constant memory, regardless of file size.

The mental model

Pick fs/promises by default. Use *Sync only for startup config. Reach for streams when files get big. Don’t read inside a hot path if you can cache the result. That covers maybe 95% of all real-world fs usage.