Notifio watches rental search pages from the user's own machine, so most of what it knows is on their disk rather than our server. Its renderer is a React UI that talks to a local Express server over 127.0.0.1, and eight of that server's routes destroy something.
I went through all eight while writing something else, and the interesting part is not the deletes. It is that five of them delete more than the button says, and all five do it for the same reason.
The eight
| Route | What the button says | What it also does |
|---|---|---|
DELETE /api/sites/:index | remove this search | removes that search's diff baseline |
PATCH /api/sites/:index with a new URL | edit this search | removes the old URL's diff baseline |
POST /api/login/reset | log out of this site | clears that host's retry state, cancels its pending login warning, switches auto-reply off for every search on it |
POST /api/license/deactivate | deactivate | stops the monitor, drops the cached upgrade entitlement |
POST /api/autoreply/reset | start over | resets the auto-reply switch, puts every search back to email-only |
DELETE /api/recipes/:id | delete this recording | nothing |
DELETE /api/finds | clear history | nothing |
POST /api/recipes/record/cancel | cancel | nothing |
The rule behind the first five: local state is keyed to other local state, and when the key goes away the value becomes a lie rather than garbage. Garbage you can collect later. A lie gets read on the next poll and acted on.
Three keys, three lies
A diff baseline is keyed by search URL. It is how the monitor knows which listings are new, and it is the whole product: without it, every check reports every listing on the page. Delete the search and the baseline is still a valid file about a URL nobody is watching:
const [removed] = cfg.sites.splice(idx, 1); saveConfig(cfg); // Clean up the removed search's baseline snapshot so it doesn't linger. if (removed) monitor.deleteSnapshot(removed.url); A "Reply for me" switch is keyed to a saved login, because a reply is submitted as the user's own account on that site. Log out and the switch is still on, and now it describes something that can only fail:
/** * Switch every search on a hostname back to email-only. * * Called when a site's session is wiped: leaving "Reply for me" on for a site * we are signed out of would show a switch that cannot do anything. Returns how * many searches changed so the caller can say so. */ function disableAutoReplyForHost(hostname: string): number { Note the return value. The route sends that count back so the UI can tell the user how many searches it just changed on their behalf. A cascade the user cannot see is worse than no cascade, and a number is the cheapest possible way to make it visible.
A cached entitlement is keyed to a licence. The app caches the server's answer to "has this licence bought the auto-reply upgrade" so a flaky connection does not switch a paid feature off mid-search. Deactivate the licence and that cache is an answer about a licence this machine no longer holds:
monitor.stop(); // Drop the cached upgrade state too, so a different licence activated on // this machine can't inherit the previous one's entitlement. entitlement.clearCache(); That is the only one of the five with a security argument rather than a coherence argument. The others prevent confusion. This one prevents a second licence inheriting the first one's upgrade.
The delete hiding inside an update
Row two is the one I would flag in review if somebody else had written it. Editing a search's URL unlinks a file:
if (oldHost === newHost && newUrl !== oldUrl) { cfg.sites[idx].url = newUrl; // The snapshot is keyed by URL, so the old baseline is now orphaned. // Drop it. The new URL re-baselines silently on its next poll. monitor.deleteSnapshot(oldUrl); } Two things make it defensible. The edit is only accepted when the hostname is unchanged, so the route cannot be used to repoint a search somewhere else entirely. And the alternative is worse: comparing the new URL's results against the old URL's baseline would alert on every listing the new search returns, all at once, which is the single most annoying failure this product has.
What the comment should also say is the user-visible consequence. Editing a filter costs one silent cycle: the first check after the edit establishes a baseline and reports nothing. Tighten a price range at 9am and the quiet minute that follows is not a bug.
Emptied, or actually unlinked?
Worth separating, because "the delete worked" covers two different outcomes:
-
config.jsonis rewritten, never removed. Keys are deleted out of it. -
finds.json, the recent-finds ring buffer, is overwritten with[]. - The auto-reply settings file is overwritten with defaults, still encrypted at rest.
- The recordings file and the cached entitlement are the two that are genuinely unlinked with
fs.rmSync. - Baselines are unlinked one search at a time, and a site's browser profile is removed recursively one host at a time.
Keeping a file and emptying it is the right default for anything the next poll has to read, and every one of these writes goes through a temp file and a rename, so a crash mid-delete cannot leave a half-written file behind:
const tmp = `${FINDS_PATH}.tmp`; fs.writeFileSync(tmp, JSON.stringify(finds), 'utf8'); fs.renameSync(tmp, FINDS_PATH); An atomic write matters more for a delete than for a save. A truncated save loses the thing you just did. A truncated delete leaves a file that parses as something else.
The one delete we decided not to build
The reply engine keeps an append-only record of reply attempts so it can answer one question across restarts: has this listing already been messaged? Every status that means "dealt with" is terminal, including failed, because a submit that errored may still have gone through and messaging a landlord twice is worse than missing one.
"Start over" forgets every recording and leaves that record alone, and there is no button that clears it. That asymmetry is deliberate, and I am not comfortable with how invisible it is. The user's reason for wanting it gone (I am finished with this city) and the engine's reason for keeping it (never contact the same listing twice) are both legitimate, and only the user can rank them. So the thing to build is not a quiet line inside the existing reset. It is a separate action with the consequence written next to it, in the sentence rather than in a tooltip.
The same applies to deactivation. It keeps your searches, your recordings and your logged-in sessions on purpose, because re-entering a token after a reinstall should not cost you an evening of setup. That is the right behaviour and nothing in the UI says it out loud, which makes it the right behaviour and a bad explanation. The fix is one sentence next to the button, and our privacy page is where the full version of it lives.
What I would take from this
- Before you write a delete, ask what was keyed to the thing you are removing. Config entries, caches, baselines, and switches that describe a capability rather than a preference. That question produced five of our eight cascades and it is faster than finding them later.
- Make cascades countable. Returning "4 searches changed" from a logout costs one integer and turns a surprise into a sentence.
- An asymmetric delete needs copy, not a comment. If "start over" keeps something, the user has to be told in the UI. A code comment explaining why is for me.
If you want to see where the behaviour is documented from the user's side rather than mine, the per-feature explanation is on notifio.app/help, the retention wording is on notifio.app/privacy, and the app itself is at notifio.app/download.


