Temp Files, Atomic Writes

Temporary Files and Atomic Writes

writeFile is not atomic. It truncates the file, then writes; a reader arriving in between sees an empty or half-written file, and a crash leaves one behind for good. Any file another process reads — configuration, a cache, a generated manifest — should be written elsewhere and moved into place.

A disposable temp directory and a crash-safe file replacementJavaScript
import { mkdtempDisposable, open, readFile, rename, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
{                                     // a unique scratch directory, removed on exit
  await using tmp = await mkdtempDisposable(path.join(tmpdir(), 'job-'));
  console.log('scratch:', path.basename(tmp.path));
}
/** Replace a file's contents so readers never see a half-written file. */
async function writeAtomic(file, data) {
  const tmpFile = `${file}.${process.pid}.${Date.now()}.tmp`;
  const handle = await open(tmpFile, 'wx');
  try {
    await handle.writeFile(data);
    await handle.sync();              // force the bytes out of the OS cache
  } finally {
    await handle.close();
  }
  await rename(tmpFile, file);        // atomic within one file system
}
await writeAtomic('config.json', JSON.stringify({ port: 3000 }));
console.log('written:', await readFile('config.json', 'utf8'));
await rm('config.json', { force: true });
Output
scratch: job-yu0awM
written: {"port":3000}

Never invent a temporary name yourself. mkdtemp asks the operating system for a directory nobody else holds, closing the symlink-attack window a predictable /tmp/myapp-cache leaves open. mkdtempDisposable, added in Node 24.4.0, returns an object with a path and a disposer, so await using removes the tree when the block exits.

Replacing a file safely: write a sibling, flush it, then rename over the target
Replacing a file safely: write a sibling, flush it, then rename over the target

Two details make or break the pattern. Put the temporary file in the same directory as its target, not in os.tmpdir(): rename is atomic only inside one file system, and across devices it fails with EXDEV. And call handle.sync() before the rename when the data must survive a power cut, or the rename can reach the disk first. If you would rather not maintain this, write-file-atomic 8.0.0 255 (github.com/npm/write-file-atomic (https://github.com/npm/write-file-atomic 255 ), npm 2,036 i write-file-atomic) is the battle-tested version — npm uses it to rewrite package-lock.json.