Quick answer: Reliable JSON file writes require six separate checks: serialize the intended snapshot before touching storage, stage it in a unique sibling file, replace the destination atomically where supported, coordinate the entire read-modify-write operation, define a durability boundary, and rehearse recovery. A successful parse proves syntax, not that every update survived.
Your settings file can remain perfectly valid while losing a customer's change. Two workers read the same version, update different fields, and both report success. The last replacement wins. This guide tests the persistence boundaries behind that failure, rather than repeating basic Node.js JSON reading and writing.
The problem is practical, not hypothetical. An August 16, 2026 OpenClaw report describes a first-write lock-ordering race. A separate July 13 Wingman report describes competing writers sharing one temporary filename. These are reports about specific implementations, not evidence that every JSON-backed application is broken.
1. Have you serialized the intended snapshot before opening the destination?
Prepare and validate the complete replacement before opening a writable destination. Serialization can fail or change the represented data, so a successful file operation is not enough. Build a plain snapshot with an explicit contract, serialize it once, and reject unsupported values before any operation can truncate the previous saved state.
Consider an export job that opens its output first and serializes later. A circular reference throws after the old file has already been damaged. Moving serialization earlier gives the application a clean failure boundary: either it has candidate bytes, or it has not started persistence.
MDN's JSON.stringify documentation explains that BigInt normally throws, object properties containing undefined are omitted, and custom toJSON methods affect the result. Decide which representations your application permits instead of accepting whatever happens to serialize.
This executable fixture makes one narrow settings contract explicit:
import assert from 'node:assert/strict';
function encodeSettings(input) {
if (!Number.isSafeInteger(input.revision) || input.revision < 0)
throw new TypeError('Invalid revision');
if (typeof input.theme !== 'string')
throw new TypeError('Invalid theme');
const snapshot = { revision: input.revision, theme: input.theme };
return JSON.stringify(snapshot, null, 2) + '\n';
}
assert.deepEqual(JSON.parse(encodeSettings({revision: 1, theme: 'dark'})),
{revision: 1, theme: 'dark'});
assert.throws(() => encodeSettings({revision: 1n, theme: 'dark'}));
assert.throws(() => encodeSettings({revision: 1, theme: undefined}));
This deliberately excludes unrelated fields; it is not a generic serializer for arbitrary objects. If those fields belong in your file, expand the contract and tests. For a larger model, use JSON Schema validation with documented validator settings. Validate the serialized representation when transformations can change meaning. Do not assume a parse round trip proves business correctness.
2. Does each write own a unique temporary file beside the target?
Create a separate temporary file for every write attempt in the destination directory, using exclusive creation rather than an existence check followed by an open. A shared staging filename lets overlapping operations overwrite or rename each other's candidate. Track the specific file you created, and remove only that file during ordinary failure cleanup.
A process identifier alone is insufficient when two asynchronous calls run inside the same process. Add a per-attempt random component, and still request exclusive creation. Random naming reduces collisions; exclusive creation makes a collision fail rather than overwrite another candidate.
Keep staging files private. A settings snapshot may contain tokens even if its filename looks harmless. Set a restrictive creation mode, secure the parent directory, and specify whether the final file should retain an existing access policy. Replacing a file also replaces the inode and can change ownership, permissions, or other metadata unless the protocol deliberately preserves them.
The following check demonstrates exclusive ownership without touching an application file:
import assert from 'node:assert/strict';
import {mkdtemp, open, unlink, rmdir} from 'node:fs/promises';
import {join} from 'node:path';
import {tmpdir} from 'node:os';
const directory = await mkdtemp(join(tmpdir(), 'json-exclusive-'));
const candidate = join(directory, 'settings.json.unique.tmp');
const handle = await open(candidate, 'wx', 0o600);
try {
await assert.rejects(open(candidate, 'wx'), {code: 'EEXIST'});
} finally {
await handle.close();
await unlink(candidate);
await rmdir(directory);
}
This tests a collision, not the statistical quality of a name generator. The Node filesystem documentation documents exclusive flags and cautions about their behavior on some network filesystems. Treat an untrusted writable parent directory as a separate security problem; unique filenames do not defeat every symlink or path-substitution attack.
3. Can readers observe only a complete old or new document?
Stage the complete candidate, close it, and replace the destination using the atomic rename semantics of the target platform and filesystem. Keep both paths on the same filesystem. Do not delete the destination first or silently substitute a copy after a failed rename, because those operations introduce different visibility and failure boundaries.
On Linux, rename replaces an existing destination atomically, while crossing mounted filesystems fails with EXDEV. That is not an invitation to copy over the live file. Return the failure and correct the staging location. Existing readers can retain an open handle to the old file; newly opened readers get the replacement after the switch.
Here is a compact visibility demonstration for a trusted local directory. It intentionally does not implement locking, metadata preservation, or crash durability:
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
import {mkdtemp, open, readFile, writeFile, rename, unlink, rmdir}
from 'node:fs/promises';
import {join} from 'node:path';
import {tmpdir} from 'node:os';
const directory = await mkdtemp(join(tmpdir(), 'json-replace-'));
const target = join(directory, 'settings.json');
const candidate = join(directory, `.settings.${randomUUID()}.tmp`);
await writeFile(target, '{"revision":1}\n');
try {
const handle = await open(candidate, 'wx', 0o600);
try { await handle.writeFile('{"revision":2}\n', 'utf8'); }
finally { await handle.close(); }
assert.equal(JSON.parse(await readFile(target, 'utf8')).revision, 1);
await rename(candidate, target);
assert.equal(JSON.parse(await readFile(target, 'utf8')).revision, 2);
} finally {
await unlink(candidate).catch(e => { if (e.code !== 'ENOENT') throw e; });
await unlink(target);
await rmdir(directory);
}
Run equivalent tests on the actual mounted volume, including Windows or network storage when applicable. This fixture checks before-and-after observations; it does not simulate power loss. For conceptual background, atomicity describes an all-or-nothing operation. Replacing one file is not a transaction across several files or a guarantee that separately computed updates are preserved.
4. Does coordination cover the read as well as the replacement?
Coordinate the whole read-modify-write cycle, not only its final write. Acquire ownership before reading the current snapshot, calculate the candidate while that ownership remains valid, and release it after the required commit boundary. Include first-time creation in the same protocol; a missing destination does not remove the possibility of competing writers.
The lost-update case needs no damaged bytes. Imagine a file with a theme and a language. Worker A reads it and changes the theme. Worker B reads the same old version and changes the language. Each produces valid JSON. Replacing A's candidate with B's candidate restores the old theme.
This deterministic model exposes the mistake without relying on scheduler timing:
import assert from 'node:assert/strict';
let saved = {theme: 'light', language: 'en'};
const a = structuredClone(saved);
const b = structuredClone(saved);
a.theme = 'dark';
b.language = 'gu';
saved = a;
saved = b;
assert.equal(saved.theme, 'light'); // A's successful change disappeared
saved = {theme: 'light', language: 'en'};
function update(mutate) {
const current = structuredClone(saved);
mutate(current);
saved = current;
}
update(x => { x.theme = 'dark'; });
update(x => { x.language = 'gu'; });
assert.deepEqual(saved, {theme: 'dark', language: 'gu'});
The second half models serialized transactions; it is not an interprocess lock implementation. A promise queue helps only callers sharing that queue. Separate processes need a common coordination mechanism, with defined timeouts, crash recovery, and ownership rules. Every writer must participate, including administrative scripts.
The write-file-atomic project documents queued same-file writes. That does not make a stale snapshot computed before entering the queue fresh. Likewise, a revision field alone cannot provide compare-and-swap if checking the revision and replacing the file are separate uncoordinated operations. Prefer a transactional database when maintaining this protocol becomes harder than the application feature it supports.
5. Have you defined what a successful save must survive?
Separate atomic visibility from crash durability. Decide whether success means another reader can observe the new file, the file data has been synchronized, or the complete replacement must survive a system crash. Implement the synchronization steps required by the deployment platform, and do not acknowledge a stronger guarantee than your tested storage protocol provides.
An awaited promise is a useful ordering boundary, but it is not a complete storage policy. The Linux fsync manual distinguishes synchronizing file contents from persisting the containing directory entry. For a Linux replacement protocol, review the sequence of writing the candidate, synchronizing it, closing it, renaming it, and synchronizing the parent directory.
Failure after rename deserves special treatment. The new file can already be visible when directory synchronization fails. Report that the commit outcome is uncertain instead of pretending the old version is intact. Blindly replaying a non-idempotent mutation can then apply it twice. Record enough non-sensitive operation context to reconcile the outcome.
Test the actual persistent volume, not only an ephemeral development directory. Include full-disk failures, denied writes, and synchronization errors. Hardware, filesystem, mount configuration, and remote storage affect the supported guarantee. The examples here do not establish power-loss safety; that requires fault testing outside a normal unit-test process.
Write down the acknowledgement rule in language your caller understands. A preferences screen might tolerate losing the latest unacknowledged edit after a crash. A payment ledger cannot casually adopt that policy. If saving several related documents must succeed together, sequentially renaming each document leaves an intermediate mixed version. Move that invariant into a transactional store or design a separately reviewed commit protocol; additional JSON formatting will not close the gap.
6. Can recovery preserve evidence without silently resetting state?
Treat recovery as a deliberate operation with its own validation and audit trail. Distinguish an expected first-run missing file from malformed JSON, access failures, and storage errors. Preserve the failed artifact securely, block automatic overwrites, and restore a known-good snapshot only after checking its structure, compatibility, and relationship to the last acknowledged update.
An empty-object fallback can turn a visible parse failure into permanent data loss. The next normal save overwrites the damaged file, destroying evidence and potentially resetting accounts, preferences, or counters. A safe default for a disposable cache is not necessarily safe for authoritative state.
Prepare a recovery rehearsal before shipping:
- Interrupt a writer before replacement and confirm the previous snapshot remains usable.
- Leave an abandoned candidate and confirm startup does not promote it merely because its timestamp is newest.
- Supply malformed and structurally invalid snapshots and confirm neither is silently saved as defaults.
- Restart competing workers and verify both intended changes survive under the real coordination mechanism.
- Restore a protected backup and check compatibility before allowing new writes.
Use JSON Compare on non-sensitive before-and-after samples to inspect changed, missing, or added values. It ignores formatting and object-key order, so it helps review the data rather than indentation. It cannot prove file locking, durability, or backup authenticity. Redact real secrets using the JSON sharing guidance before creating diagnostic copies.
The release decision is simple: do not stop at “the file parses.” Verify that the right values survived, that competing changes were coordinated, and that an interrupted save has an explicit recovery path. Those are separate assertions, and each deserves its own test.
Keep diagnostic output focused on stages rather than whole documents. Record whether failure occurred during candidate creation, writing, replacement, or synchronization, together with a safe operation identifier. That makes an uncertain commit distinguishable from a rejected candidate without leaking the settings themselves. Establish retention and access rules for abandoned temporary files and backups, because both can contain older credentials. Finally, rehearse recovery with a different operator: a backup that only its original author knows how to restore is not yet an operational procedure.