When Notifio finds a listing that was not on a rental search page the last time it looked, four things happen. A line goes into the activity log, a row goes into the recent-finds list, a native desktop banner appears, and our server sends an email.

Exactly one of those four can be turned off, and the route that does it carries the policy in a comment:

/** * Local alert channels. Only the desktop banner is configurable: the email is * the licensed part of the product and the activity log is always on. */ app.patch('/api/config/notifications', (req: Request, res: Response) => { const { desktop } = req.body ?? {}; if (typeof desktop !== 'boolean') { return res.status(400).json({ error: 'desktop must be true or false' }); } const cfg = loadConfig(); cfg.notifications = { ...(cfg.notifications ?? {}), desktop }; saveConfig(cfg); res.json({ ok: true, notifications: cfg.notifications }); }); 

This post is about why three of the four have no switch, and about the five lines of type and comparison that make the fourth one default to on without a migration.

The switch is an absent key

The stored shape is one optional boolean inside an optional object:

/** Local alert channels. Absent means on, so existing configs get them. */ notifications?: { /** Native desktop banner the moment a listing is found. */ desktop?: boolean; }; 

Desktop banners were added to an app that already had users with a config.json on disk. Two ways to handle that: write a migration that stamps { desktop: true } into every existing config, or decide that absent means on and never write anything until the user touches the switch.

We took the second, which means every read has to be a comparison against false rather than a truth test:

// in the monitor if (fresh.length > 0 && config.notifications?.desktop !== false) { notifyFinds(site.name, fresh, log); } 
// in the renderer, hydrating the UI from the config enabled: configRes.data.notifications?.desktop !== false, 

!== false reads like a tic until you see what the alternatives cost. === true switches the feature off for everybody who installed before it existed. !!value does the same thing. A migration means a version field, a comparison, and a write at startup, which is three new ways for an app launch to fail in exchange for a default.

The asymmetry is the part worth copying: reads are permissive, the write is strict. The PATCH route refuses anything that is not a literal boolean with a 400, so the only values that ever reach disk are true and false. Undefined is a state the file is allowed to be in, but not a state the API can put it in.

Why the other three have no switch

The activity log. It is the only place a user can see what the app did when it found nothing, which is most of the time. A monitoring app whose log can be silenced is a monitoring app that cannot be debugged over email by the person who built it. It is also 200 lines in memory rather than a file, so there is nothing to opt out of.

The recent-finds row. The reason this exists at all is that a find used to exist only as an email. Miss the email, or have the send fail, and there was nothing in the app to look at. Making that history optional would recreate the hole it was built to fill.

The email. This is the one people expect a switch for, and it is the one that cannot have one. It is the only channel that reaches somebody who is not sitting at the machine: our server sends it, so it arrives while the laptop is locked and the screen is off, as long as the app is running. A user who turns it off has turned the product into a dashboard. The honest version of that switch is quitting the app, and that one already exists.

The recipient is not configurable either. It is set once, at activation, to the email on the licence:

cfg.license = { email: normalEmail, token: normalToken }; // Set the alert recipient to the license email cfg.email = { to: normalEmail }; 

One address, the one we already have to know, which removes a settings field and a class of support ticket where alerts are going somewhere nobody reads. The trade is real: somebody who wants alerts at a different address cannot have that today, and the help page has a whole section on getting Gmail to put them in the Primary tab instead, which is the workaround for a problem a second address field would also solve.

The ordering is deliberate

Of the four, two are instant and local, one is a native OS call, and one crosses the internet. They do not fire in the order they are written in, they fire in the order that gets the user to the listing first:

// The local channel goes first. It costs no network round trip, so the // banner is on screen before the email has left the building, and on a // rental site the first few minutes are the whole difference. const fresh = finds.add(newListings.map(/* ... */)); if (fresh.length > 0 && config.notifications?.desktop !== false) { notifyFinds(site.name, fresh, log); } 

The email is pushed onto an alerts array and sent after the whole poll cycle finishes, because one email about four searches beats four emails, and because a send that hangs must not delay the banner for the next search.

This is the part of the design I would defend hardest. Notifio's entire argument against portal alert emails is that a queue sits between publication and you. If the fastest channel the product owns waited on the slowest one, the argument would be theatre.

Deduplication happens in the store, not the notifier

finds.add() takes the listings the diff thinks are new, and returns only the ones it had never seen:

export function add(items: Omit<Find, 'foundAt'>[]): Find[] { const existing = read(); const seen = new Set(existing.map((f) => f.id)); const fresh = items.filter((item) => !seen.has(item.id)).map(/* ... */); if (fresh.length === 0) return []; write([...fresh, ...existing].slice(0, MAX_FINDS)); // ... return fresh; } 

The banner fires on that return value, not on the diff's output. So a listing that drops off a results page and comes back, or a search that re-bases, cannot produce a second banner for the same listing, and no code in the notifier knows anything about deduplication. One store owns identity, and every surface downstream inherits it.

It also means the banner is per search rather than per listing, which was a deliberate limit:

/** * One notification per search rather than per listing: five separate banners * for five rooms in the same search is a notification the user turns off. */ 

Five banners for five rooms is how you get a user to find the switch. There is a nice circularity to that: the only channel with an off switch is the one whose design is most constrained by not wanting it used.

The toggle itself is optimistic, with a rollback

The last piece is 6 lines in the renderer:

const setDesktopNotifications = useCallback(async (enabled: boolean) => { dispatch({ type: "desktop_notifications", enabled }); const { ok } = await apiSetDesktopNotifications(enabled); // Put the switch back if the write failed, rather than showing a // preference that was never saved. if (!ok) dispatch({ type: "desktop_notifications", enabled: !enabled }); }, []); 

The server is on 127.0.0.1 in the same app, so the round trip is sub-millisecond and the optimistic update is almost pointless. The rollback is not. A local server can still fail to write: a full disk, a permissions problem on the data folder, a malformed config it had to fall back on. A switch that stays where you left it after a failed write is a lie that survives a restart, and the most annoying version of this bug is the one where the preference reappears wrong only after the app is reopened, days later, by which point nobody connects the two events.

The rule I took out of it

For any user-visible channel, three questions, in this order:

  1. Can this be switched off without turning the product off? If no, there is no switch, and that is a product answer rather than a UI omission. Write it in the code where somebody will ask.
  2. If yes, what does an absent value mean? Pick "on" and read it as !== false, or pick a migration, and know that the migration is a startup failure mode you are buying for the sake of a tidier file.
  3. What happens if the write fails? The optimistic update is for feel. The rollback is for truth.

The switch, the four surfaces and the email are all visible in the running app, which you can get from notifio.app/download. The behaviour per rental site, including what the site's own alerts do and where that leaves you, is documented per site under notifio.app/alerts, and notifio.app/help is where the email side is explained to the person receiving it rather than the person shipping it.