Notifio is an Electron app that watches rental search pages and tells you the moment a listing appears. It stores everything it knows in plain files in the user's app data directory, because it picked NDJSON over SQLite for reasons to do with the build pipeline and because each of those files dies at a different moment.
This post is about the part I did not cover in either: what happens when one of those files is unreadable. Which is a question you have to answer separately per file, and I did not realise that until I had answered it wrong once.
The write side is three lines, six times
Here is the config write:
function saveConfig(cfg: Config): void {
try {
// Atomic write: write to a temp file then rename, so a crash mid-write
// can never leave a half-written (corrupt) config.json behind.
const tmp = `${CONFIG_PATH}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2), 'utf8');
fs.renameSync(tmp, CONFIG_PATH);
} catch (err) {
console.error('[server] Failed to write config:', err);
}
}
And the diff snapshot write, and the finds history write, and the entitlement cache write, and the encrypted settings write, and the ledger compaction rewrite. Six files, the same tmp then renameSync, because rename over an existing path on the same filesystem is atomic: any reader sees either the whole old file or the whole new one, never a prefix.
Be honest about what that does and does not buy you. There is no fsync anywhere in this app. Atomic rename protects against a torn file, not a lost write: pull the plug at the wrong microsecond and you can still come back to yesterday's snapshot, because the rename was in the page cache and never reached the disk. For this product that trade is correct and deliberate. A lost snapshot costs one silent re-baseline of one search. An fsync on every write costs a disk flush every thirty seconds, forever, on somebody's laptop.
The one file that does not use the pattern for its normal writes is the reply ledger, and its header says why:
/**
* NDJSON rather than sqlite on purpose: no native module, so the existing
* electron-builder pipeline (already fiddly with the bundled Playwright
* browsers) stays untouched. Updates are appended as a new row with the same
* id and readers fold last-wins, which keeps writes atomic by construction.
*/
An append of one line under the pipe buffer size does not need a rename to be safe. The rename only appears when the file is compacted, at 8000 rows down to 2000, which is the one moment that file is rewritten rather than extended.
Then every read has to pick a direction
That is the whole shared part. Every one of these files also has a read path, and the reads are all different, because "this file is garbage" means something different per file and the wrong fallback is worse than a crash.
config.json: degrade rather than throw.
function loadConfig(): Config {
try {
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
} catch (err) {
// A missing/corrupt config must not crash every request, fall back to a
// safe empty config instead of throwing out of every route handler.
console.error('[server] Failed to read/parse config, using defaults:', err);
return { email: { to: '' }, sites: [] };
}
}
Every route in the local API calls this. Letting it throw means the window loads and every single request 500s, including the ones that would let the user fix the problem. An empty config gives you an app that says you have no searches, which is wrong but recoverable by hand.
A diff snapshot: return null, which means "no baseline".
function loadSnapshot(url: string): Listing[] | null {
const file = snapshotPath(url);
if (!fs.existsSync(file)) return null;
try {
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
The Array.isArray check is the interesting half. A file containing valid JSON that is not an array is just as useless as a torn one, and treating "parsed fine" as "usable" is how you get a TypeError two call frames away from the thing that was actually wrong.
null here does not mean an error. It means this search has nothing to compare against, so its next check writes a baseline and deliberately tells the user nothing. That is the correct failure for an alerting app: one missed comparison. The alternative, treating an unreadable baseline as an empty page, would make every listing on it look new and fire an email about forty rooms that have been listed for weeks.
The finds history: an empty list, and a comment about the poll loop.
} catch {
// Missing or corrupt: an empty history is a fine place to start and must
// never stop the monitor.
_cache = [];
}
The second clause is the real rule. This file is written from inside the poll cycle, so anything it throws lands in the middle of the app's only job.
The ledger: skip the bad line, keep the rest.
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
rows.push(JSON.parse(trimmed) as T);
} catch {
// A torn final line (power loss mid-append) must not poison the read.
}
}
This is the one place where a partial read is strictly better than a fallback, and it is a direct consequence of the format. The ledger exists to answer "have we already messaged this listing", so losing all of it means messaging a set of landlords a second time, which is the single worst thing this product can do. Line-delimited JSON degrades one row at a time, and the one row you lose to a power cut is the newest, which is the one the app is least likely to need to have remembered.
Two of the six fail closed, and that is a money decision
The entitlement cache records whether this licence has the auto-reply upgrade. Its read does something the others do not:
const parsed = JSON.parse(fs.readFileSync(ENTITLEMENT_PATH, 'utf8')) as CachedEntitlement;
if (!parsed || typeof parsed.fetchedAt !== 'string') return null;
// Anything other than an explicit `true` fails closed.
_memory = { ...parsed, autoReply: parsed.autoReply === true };
parsed.autoReply === true, not !!parsed.autoReply, and not a default. undefined, "yes", 1, and a file that got truncated to something plausible all resolve to "not upgraded". The same choice is in the encrypted settings store, where an undecryptable file becomes the defaults and the defaults have auto-reply off.
This is the same instinct as distinguishing a 429 from a real "no" when checking a licence, pointed the other way. When the question is "is this person allowed to use a paid feature" and the answer on disk is unreadable, off is recoverable in one round trip to the server and on is not recoverable at all.
Notice that these two files fail in the opposite direction from the other four. The alert side of the app fails towards silence: no baseline, no history, no banner, and the user loses one cycle of information. The paid side fails towards closed: the feature switches off and a server check turns it back on. Getting those two directions the wrong way round produces the two bugs a small product cannot afford, which are spamming a landlord and giving a feature away.
The rule, stated as a checklist item
None of this is clever engineering. It is one boring write pattern plus a question I now ask of every JSON.parse in the app:
When this file is unreadable, what does the user experience, and is that the cheap failure or the expensive one?
Six files, five different answers, and the only one I would call subtle is the strict === true on the entitlement cache. A truthiness check there would read identically in review, pass every test written against a healthy file, and fail open on the one input nobody writes a test for.
If you want to see what the thing built on top of these files actually does, the app is a free download for Mac and Windows, the per site coverage is under alerts including Kamernet, Pararius and OpenRent, and what is stored where is written out in plain English on the privacy page.
Top comments (0)