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