Safely overwrite a JSON config
The pattern: a config file that other tools watch (a dev server, a linter, your own application’s hot-reload). You need to read it, change a field, write it back — and you don’t want a half-written file to ever be observable.
The recipe
Section titled “The recipe”import { getJsonFileAsync, atomicWriteJsonAsync, fileExistsAsync } from "@warlock.js/fs";
type Config = { apiUrl: string; features: Record<string, boolean>; updatedAt: string;};
async function setFeature(name: string, enabled: boolean) { // Read current state — fall back to defaults if the file doesn't exist yet. const current: Config = (await fileExistsAsync("./config.json")) ? await getJsonFileAsync<Config>("./config.json") : { apiUrl: "https://api.example.com", features: {}, updatedAt: "" };
// Mutate. current.features[name] = enabled; current.updatedAt = new Date().toISOString();
// Write atomically — any watcher sees the old config or the new, never a // truncated half-written JSON file. await atomicWriteJsonAsync("./config.json", current);}
await setFeature("dark-mode", true);Why each call
Section titled “Why each call”fileExistsAsync + fallback. Better than try/catching the read —
makes the “first run, no file yet” branch explicit and removes the
ENOENT-as-control-flow smell.
getJsonFileAsync<Config>. Typed read. If the file’s been corrupted
(someone hand-edited it badly), this throws SyntaxError — let it bubble
up so the calling code knows.
Mutate in memory. The mutation is a plain object assignment. Nothing touches disk between read and write.
atomicWriteJsonAsync. The whole point — readers see the old file or
the new file, never a half-written one. A crash between the read and the
write leaves the file unchanged.
What this doesn’t protect against
Section titled “What this doesn’t protect against”Lost updates. If two callers run setFeature concurrently, they both
read the same starting state and one of their writes is silently
overwritten by the other. The atomic write doesn’t lock — it just makes
each individual write safe to observe.
If lost updates matter, wrap the read-modify-write in a lock. Two options:
- In-process — use a simple
async-aware mutex (any small library, or hand-rolled with a Promise queue). - Cross-process —
@warlock.js/cache’scache.lock()backed by Redis or Postgres.
import { cache } from "@warlock.js/cache";
// cache.lock(key, ttl, fn) acquires, runs, and auto-releases.const outcome = await cache.lock("config.json", "1m", async () => { // Same read-modify-write as above, but now serialized across the cluster. await setFeature("dark-mode", true);});
if (!outcome.acquired) { // Another worker holds the lock — your fn did not run.}Variation: append-only journal
Section titled “Variation: append-only journal”Same idea, different shape — an event log where each call adds an entry:
import { getJsonFileAsync, atomicWriteJsonAsync, fileExistsAsync } from "@warlock.js/fs";
type Event = { ts: string; type: string; payload: unknown };
async function appendEvent(event: Event) { const existing: Event[] = (await fileExistsAsync("./events.json")) ? await getJsonFileAsync<Event[]>("./events.json") : [];
existing.push(event); await atomicWriteJsonAsync("./events.json", existing);}Same lost-update caveat applies — under concurrent appends, only one
writer’s append survives. For a real event log, use a database or a
proper append-only file format (NDJSON with appendFile); the recipe
above is for low-volume single-writer logs (build emitters, CLI tools).
Related
Section titled “Related”- Write atomically — the underlying mechanism.
- Atomic vs non-atomic — when to reach for atomic vs plain writes.