C.W.K.
Stream
Lesson 01 of 05 · published

fs/promises — File I/O the Modern Way

~12 min · io-net, fs, filesystem

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"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

If your process dies mid-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

For my first year I used 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.

Code

Atomic JSON write — the cwkPippa pattern·javascript
// A real atomic-write helper from cwkPippa's pattern
import { writeFile, rename, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import { randomUUID } from 'node:crypto';

export async function atomicJsonWrite(path, value) {
  await mkdir(dirname(path), { recursive: true });
  const tmp = `${path}.${randomUUID()}.tmp`;
  await writeFile(tmp, JSON.stringify(value, null, 2));
  await rename(tmp, path);
}

// Used like:
await atomicJsonWrite('~/pippa-db/sessions/abc.jsonl', {
  conversation_id: 'abc',
  messages: [],
});
Async recursive walk — no third-party dep needed·javascript
// Recursive directory walk using fs/promises
import { readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';

async function* walk(dir) {
  const entries = await readdir(dir, { withFileTypes: true });
  for (const entry of entries) {
    const full = join(dir, entry.name);
    if (entry.isDirectory()) yield* walk(full);
    else if (entry.isFile()) yield full;
  }
}

// Use it as an async iterator
for await (const path of walk('./src')) {
  if (path.endsWith('.ts')) console.log(path);
}

External links

Exercise

Build safeUpdateJson(path, updater) — atomically reads a JSON file, passes the parsed object to updater(obj), then atomically writes the result back. Test it under concurrent load: spawn 10 child processes that each call safeUpdateJson to increment a counter in the file. After they all finish, the counter should equal 10 — proving your atomic write didn't lose updates. (Hint: this requires a lock layer too; atomic write isn't enough alone.)
Hint
Atomic write prevents half-files, but doesn't prevent lost updates. Two writers can each read the same value, increment, write — final value is 1 instead of 2. The simplest cross-process lock in Node is fs.open(lockfile, 'wx') (fail if exists), acquire, work, delete. Or use a library like proper-lockfile. The exercise is the point: atomic ≠ serialized.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.