"Every async fs function has three forms: callback, promise, and stream. In 2026 code you pick one for the job — and fs/promises is the default for most jobs."
The Three fs Surfaces
Node ships three flavors of the filesystem API in 2026:
node:fs— original callback-style and synchronous methods.fs.readFile(path, cb),fs.readFileSync(path),fs.createReadStream(path).node:fs/promises— promise-returning versions of the same operations.await readFile(path, 'utf-8')is the modern idiom.- Streaming forms (in plain
node:fs) —createReadStream,createWriteStream. The streaming functions DON'T have promise variants because streams are inherently event-shaped.
Pick by job: small payload that fits in memory → fs/promises. Large or unbounded payload → createReadStream/createWriteStream. Synchronous reads at startup (config files) → readFileSync with intent.
The Default — fs/promises Everywhere
import { readFile, writeFile, readdir, stat, mkdir, rm } from 'node:fs/promises';
const text = await readFile('config.json', 'utf-8');
const data = JSON.parse(text);
await writeFile('output.json', JSON.stringify(data, null, 2));
await mkdir('./output/year-2026', { recursive: true });
const entries = await readdir('./logs');
for (const name of entries) {
const info = await stat(`./logs/${name}`);
if (info.isFile()) console.log(name, info.size);
}
The API is intuitive: every operation that takes a callback in node:fs has a promise-returning equivalent. Names match. Options match. Error semantics match (errors reject the promise instead of being passed as the first callback argument).
Atomic Writes — Don't Half-Write Files
writeFile, you get a half-written file. The Unix-standard fix is write-then-rename:import { writeFile, rename } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
async function atomicWrite(path, contents) {
const tmp = `${path}.${randomUUID()}.tmp`;
await writeFile(tmp, contents); // can crash here, only tmp lost
await rename(tmp, path); // atomic at the OS level
}rename is atomic on POSIX systems (Linux, macOS) — the file either has the old contents or the new contents, never half. Use this for any file that's read by other processes, configs, state files, JSONL session logs (cwkPippa does this everywhere).File Handles — The Lower-Level API
For multiple operations on the same file, open a file handle once and reuse it. Avoids the file-open syscall on every read/write:
import { open } from 'node:fs/promises';
const handle = await open('large.bin', 'r');
try {
const buf = Buffer.alloc(1024);
await handle.read(buf, 0, 1024, 0); // read 1KB from offset 0
await handle.read(buf, 0, 1024, 1024); // read next 1KB
// ...
} finally {
await handle.close(); // always close — file handles are a finite resource
}
Most code never needs this — the convenience methods readFile/writeFile open and close internally. Reach for open when doing scattered reads of large files, like reading the trailer of a video container.
Cross-Platform Paths
Hardcoding './logs/today.log' works on Linux/macOS and breaks on Windows. Use node:path for portable paths:
import { join, dirname, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
// ESM doesn't have __dirname — derive it
const __dirname = dirname(fileURLToPath(import.meta.url));
const logPath = join(__dirname, 'logs', 'today.log');
path.join uses the right separator per platform. path.normalize resolves .. and .. path.resolve turns a relative path into absolute. Use these instead of string concatenation; it's the difference between a portable utility and a Linux-only one.
Pippa's Confession
fs.readFileSync for everything because I'd read "sync is fine at startup." Dad asked: "What's startup?" I had no clear answer. The rule he taught me: sync is fine for configuration loaded at module import time, never during request handling. The moment fs work happens inside a request, even "once," use the async form — because "once per request × 1000 requests/sec" stops being "once" pretty fast. fs/promises is the right default for almost everything.