A small CLI reads config.json, changes one field, and writes the file back. The code looks harmless:
await writeFile(path, JSON.stringify(next, null, 2));
Then the process is interrupted during the write. The next run finds 347 bytes of what used to be a 2 KB file, JSON parsing fails, and a one-line settings command has become a recovery exercise.
The useful contract is not “the write usually succeeds.” It is:
After an interruption, the config is either the complete previous version or the complete next version—never a partial mixture.
Here is a small Node.js implementation and a way to test its failure path without pulling in a database.
Why direct overwrite is the wrong primitive
Opening an existing file for writing commonly truncates it before replacement bytes are complete. If the process exits, the machine loses power, or the filesystem reports an error, the old valid state is already gone.
A safer sequence is:
serialize fully
-> write sibling temporary file
-> fsync temporary file
-> rename temporary over destination
-> fsync parent directory
Rename on the same filesystem is the commit point. Readers observe the old name before commit and the new file after commit.
The 50-line writer
import { open, rename, rm } from "node:fs/promises";
import { dirname, basename, join } from "node:path";
import { randomUUID } from "node:crypto";
export async function writeJsonAtomic(path, value) {
const directory = dirname(path);
const temporary = join(directory, `.${basename(path)}.${randomUUID()}.tmp`);
const body = `${JSON.stringify(value, null, 2)}\n`;
let handle;
try {
handle = await open(temporary, "wx", 0o600);
await handle.writeFile(body, "utf8");
await handle.sync();
await handle.close();
handle = undefined;
await rename(temporary, path);
const directoryHandle = await open(directory, "r");
try {
await directoryHandle.sync();
} finally {
await directoryHandle.close();
}
} catch (error) {
if (handle) await handle.close().catch(() => {});
await rm(temporary, { force: true }).catch(() => {});
throw error;
}
}
The temporary file is created beside the destination so rename() does not cross filesystems. wx prevents accidental reuse. Mode 0600 avoids briefly creating a world-readable file for configs that may contain secrets.
The directory sync matters for durability across sudden power loss on filesystems where the directory entry can lag behind file data. Some platforms or filesystems do not support directory fsync; decide whether your CLI requires crash durability or only process-interruption safety, then test on supported targets instead of swallowing every error.
Do not mutate the in-memory object first
Separate parsing, validation, and commit:
const current = JSON.parse(await readFile(configPath, "utf8"));
const candidate = { ...current, output: requestedOutput };
validateConfig(candidate);
await writeJsonAtomic(configPath, candidate);
If validation fails, no write starts. If serialization fails, no temporary file is committed. If rename fails, the old destination remains.
Also reject non-finite numbers or values your schema cannot round-trip. JSON silently turns NaN and Infinity into null, which is atomic corruption: complete, durable, and wrong.
Test the interruption window
Unit tests that mock writeFile only prove call order. A stronger fixture starts a child process, pauses it after the temporary file is durable, kills it, and verifies that the destination is still valid.
Add a test-only synchronization hook:
if (process.env.ATOMIC_WRITE_PAUSE === "after-sync") {
process.stdout.write("PAUSED\n");
await new Promise(() => {});
}
Place it after handle.sync() and before rename(). The parent test can then:
- Write a known old config.
- Spawn the update command with the hook enabled.
- Wait for
PAUSED. - Send
SIGKILL(or terminate the process using the platform's supported mechanism). - Parse the destination and confirm it equals the old config.
- Run normally and confirm it equals the new config.
- Clean stale
.*.tmpfiles created by the test.
A second hook after rename tests a different state: the destination must parse as the complete new config, even if cleanup or directory sync did not finish.
Expected evidence:
kill-before-rename: destination=old, parse=ok
kill-after-rename: destination=new, parse=ok
normal-run: destination=new, parse=ok
Concurrency is a separate bug
Atomic replacement prevents torn files. It does not prevent lost updates:
process A reads version 7
process B reads version 7
A writes output=table
B writes color=false
A's change disappears
For a single-user CLI, a lock file with owner PID, creation time, bounded wait, and stale-lock recovery may be enough. Another option is optimistic concurrency: store a revision, re-read immediately before commit, and refuse if it changed.
Do not claim the atomic writer solves concurrency. It solves one narrower and valuable failure: incomplete replacement.
Recovery policy
Temporary files can remain after a hard kill. On startup, do not automatically promote the newest temp file. Its presence does not prove the user intended to commit it, and wall-clock ordering can lie.
A conservative policy is:
- keep the named destination authoritative;
- validate it before use;
- remove stale temp files only when no writer lock is active;
- show a repair command if the destination itself is invalid;
- never print secret config contents in the error.
Exit criteria
This pattern is worth keeping when the CLI owns a small local state file and needs zero extra services. Move to SQLite or another transactional store when updates involve multiple records, cross-process coordination, queries, or migrations. A clever file protocol is not a tiny database replacement.
The implementation is small, but the important part is the failure fixture. If the test cannot kill the writer on both sides of the commit point, “atomic” is still an assumption.
Top comments (0)