Notifio is an Electron app that watches rental search pages and tells you the moment a new listing appears. It is not a big app. It has one job, a poll loop, and a handful of files on disk.
It also has four separate places state lives, and for a long time I could not have told you, quickly, why any given piece of state was in the one it was in. When I finally wrote it down the answer was the same question every time, and it was not "what is this data" or "who writes it". It was when is this allowed to die.
Here is the map. Each of these has a post of its own already, and I will link them as I go. What this post adds is the axis they all sit on, because lining them up that way is what made the design stop feeling arbitrary.
| What | Where it lives | Dies when |
|---|---|---|
| Live per-search run state | Memory | The monitor stops |
| Diff baselines | One file per search | The search is re-pointed, or a check supersedes it |
| Recent finds | One capped JSON file | It falls off the end of a 200 item ring |
| Reply ledger | Append-only NDJSON | Never, in practice |
1. Run state: dies with the monitor, on purpose
This is "checking...", "last checked 4s ago", "12 listings", "blocked, retrying in 3m". It is owned by the main process and it is in memory only.
/**
* Deliberately in memory only: it describes the current run, and a stale
* "checking..." restored from disk after a restart would be a lie.
*/
const _states = new Map<string, SiteRuntime>();
The reason it is in the main process at all is that the renderer window reloads every time you open it, so the renderer cannot be the owner of anything it wants to still have later. But the reason it is not persisted is the comment above. Every field in that struct is a claim about right now. Write it to disk and you have built a machine for telling users things that were true yesterday.
There is one nice property that falls out of choosing memory: the hard part of persistence, deciding what to invalidate, disappears. stop() clears the whole map and the correctness argument is one line.
The debounce is the only other thing in there worth mentioning, and it exists because of how the writes arrive:
// Changes arrive in bursts (a poll touches every search in turn). Emitting on
// each one would push a stream of near-identical snapshots at the renderer, so
// they are coalesced into one frame.
const EMIT_DEBOUNCE_MS = 120;
2. Baselines: die when they are superseded, and not one moment sooner
Each search has a file containing the listings that were on its page at the last successful check. One file per search, named by a hash of the URL:
function snapshotPath(url: string): string {
const hash = crypto.createHash('sha1').update(url).digest('hex').slice(0, 16);
return path.join(DATA_DIR, `${hash}.json`);
}
Two decisions here have teeth.
The write is atomic. Temp file, then rename. A snapshot half-written during a power cut is not a corrupt baseline, it is a missing temp file and an intact previous baseline.
The write is deferred until the alert has actually been sent. This is the one I would keep if I could keep only one:
// Snapshots for sites that produced alerts are persisted ONLY after the
// notification is delivered. Otherwise a failed send would record those
// listings as "seen" and they'd never be alerted again.
const pendingSnapshots: Array<{ url: string; listings: Listing[] }> = [];
// ...later, once the email is away:
if (alerts.length > 0) {
try {
await sendListingAlert(alerts, config);
// Delivery confirmed, so now it is safe to record these listings as seen.
for (const snap of pendingSnapshots) saveSnapshot(snap.url, snap.listings);
} catch (err) {
log(`[notify] Failed to send: ${message}. Keeping listings unseen, will retry next poll`);
}
}
The obvious ordering is: scrape, save what you saw, send the email. It reads fine and it is a silent data-loss bug. The snapshot is not a record of what the page contained. It is a record of what the user has been told about. Those two things are the same right up until the send fails, and then the listing is marked seen forever and nobody ever hears about it. Writing the snapshot last turns a failed send into a retry on the next poll, which is thirty seconds away.
The other lifetime rule for baselines is the one I wrote about yesterday: the first check after the app restarts re-bases instead of alerting, because a baseline from a previous run describes a page nobody was watching.
3. Finds: die by age, and nothing else is allowed to kill them
This is the list of listings the app has found, shown in the app so you can look at this morning's haul without going to your inbox. It is a capped ring buffer in one file:
/**
* How many finds to keep. A busy hunt produces a few hundred a week; this is
* enough to cover "what did I miss overnight" without the file growing forever.
*/
const MAX_FINDS = 200;
The interesting thing is not the ring. It is that this file exists at all, when the baseline snapshots already contain listings. Why store the same URLs twice?
Because their lifetimes are incompatible, and I only worked that out by trying to share:
It is separate from the per-search snapshots on purpose: those are a diffing baseline keyed by URL and get deleted whenever a search is re-pointed, which is exactly when the user least wants their history to vanish.
Re-pointing a search means editing its URL, usually to widen or narrow the filters. The old baseline has to go, or the first check against the new URL would diff against a different search's page and treat the entire result set as new. But "I changed my price filter" is not a request to delete the eleven rooms the app found for you this morning. One store, and you have to pick which of those two users to disappoint.
Two smaller details, both about the same idea of making the data structure do the work:
export function add(items: Omit<Find, 'foundAt'>[]): Find[] {
// ...
const fresh = items.filter((item) => !seen.has(item.id));
// ...
return fresh;
}
add returns only the items it actually added. That return value is what the desktop notification is built from, so it is structurally impossible to show a banner for a listing that was already in the history. The dedupe and the notification cannot disagree, because there is only one filter.
And the find history lives in its own file rather than in config.json:
// The recent-finds history shown in the app. Kept out of config.json so a long
// history can never make the file the monitor reads every poll any bigger.
export const FINDS_PATH = path.join(USER_DATA_DIR, 'finds.json');
Same reasoning as the auto-reply data sitting in its own files: a corrupt history must never be able to stop the app finding listings.
4. The reply ledger: effectively immortal, and that is the feature
The auto-reply engine needs to answer one question across restarts, reinstalls and everything else: has this listing already been messaged? Messaging a landlord twice is a real-world embarrassment, not a log line.
So this one is append-only NDJSON, folded last-wins on read, and chosen over SQLite for build pipeline reasons I wrote up separately. Its lifetime rule is inverted from everything above: rows are only ever removed by compaction, and the statuses that mean "handled" include the ones you would expect to want to retry. failed is deliberately terminal, because a submit that errored may still have gone through.
Notice how this inverts the baseline rule from section 2. There, a failed send means "do not record it, try again". Here, a failed send means "record it, never try again". Same word, opposite handling, and the difference is entirely about who pays for the mistake. A duplicate email to yourself costs nothing. A duplicate message to a landlord costs you the flat.
What I would do differently from the start
Ask the lifetime question first. Not the schema question, not the storage question. For every piece of state: what event makes this wrong?
Every non-obvious decision above is downstream of that one question, and in each case the wrong version of the code was the version that looked tidier. Sharing one listings store between diffing and history is tidier. Saving the snapshot right after the scrape is tidier. Clearing all state in stop() is tidier. All three are bugs, and all three read fine in review.
Go and look
- notifio.app and the download page. Everything above is describing the app you can actually run
- The help page, which is where the user-facing version of these rules ends up
- The sites it monitors, if you want the product context for why a missed alert matters so much
- How to be first to a rental listing, which is the reason a failed email has to become a retry rather than a shrug
Top comments (0)