Atomic file writes: write a temp file, then rename
Overwriting in place leaves half a file behind on power loss, a crash, or a concurrent read. A temp file plus rename uses filesystem atomicity to make half a file impossible.
Writing to a file someone else is reading has a dozen ways to leave it half written: the process is killed, power drops, the disk fills, a reader is mid-read. The fix is not a lock. It is to make a half-written file invisible.
rename is atomic
A rename within one filesystem is atomic: the destination either points at the old content or the new one, never something in between. So split the write into three steps:
import { writeFile, rename, unlink } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
export async function atomicWrite(path: string, data: string) {
const tmp = path + '.' + randomBytes(6).toString('hex') + '.tmp';
try {
await writeFile(tmp, data, 'utf8');
await rename(tmp, path); // atomic replace
} catch (err) {
await unlink(tmp).catch(() => {}); // clean up, keep the original error
throw err;
}
}
The hard constraint: the temp file must be on the same filesystem as the target. A cross-filesystem rename degrades into copy plus delete and the atomicity is gone. So put the temp file next to the target, not in /tmp.
Why not write then truncate
open(path, 'w') truncates immediately. Crash at that moment and the file is already zero bytes with the old content gone. It is the most common self-destruct pattern.
// do not do this
await writeFile(path, data);
Do you need fsync
writeFile returning only means the data reached the page cache, not the platter. To survive power loss you also fsync the file and the directory:
import { open } from 'node:fs/promises';
const fh = await open(tmp, 'w');
await fh.writeFile(data);
await fh.sync(); // data to disk
await fh.close();
await rename(tmp, path);
const dir = await open(dirname(path), 'r');
await dir.sync(); // directory entry to disk
await dir.close();
The cost is waiting on the disk for every write. Do it for config and state snapshots; skip it for high-frequency log appends.
Randomize the temp name
A fixed .tmp name makes two concurrent writers stomp each other. With a random suffix each writes its own file, and the last rename wins. The result is still one complete version, never a blend.
Cross-platform note
On Windows, renaming onto an existing target fails with EPERM or EEXIST. Node’s fs.rename handles this, but in other languages you may have to delete the target first, which throws away atomicity and needs a lock to restore.
Atomic writing in one sentence: build the new content elsewhere, then swap it in with a single atomic operation.

Comments
…