DEV Community

Daniel Pertu
Daniel Pertu

Posted on

The first check after a restart is not allowed to tell you anything

Notifio is a desktop app that watches rental search pages and tells you the moment a new listing appears. It polls the search results page you actually use, compares what is there now against what was there on the last check, and alerts on the difference.

I have written before about how much of that diff code exists purely to not send an alert. That post was about deciding which listings in a page are genuinely new. This one is about something earlier and, it turned out, harder: deciding whether a check is allowed to alert at all.

The bug

You quit the app at 22:00 and reopen it at 09:00 the next morning.

The app has a saved snapshot of every search from last night. It checks Kamernet, finds 40 listings that are not in that snapshot, and emails you about all 40. Every one of them was posted while the app was closed. Every one of them has been sitting in the inbox of everyone whose app was open. On a housing site that means they are gone, and you are reading an email about rooms somebody else viewed eleven hours ago.

That was the good case. The bad case is worse and less obvious. The monitor has a guard that refuses to alert when too much of a page has changed, because a page that is 80% different is usually a layout change, a redirect, or a logged-out view, not forty new rooms. A snapshot from last night trips that guard constantly. So the common outcome was not "forty useless emails". It was silence, from an app whose entire job is to not be silent.

Both symptoms have the same cause. A stored baseline does not record what the page held a moment ago. It records what the page held when the app was last open.

The first fix, which was wrong

The obvious rule is a staleness threshold. If the snapshot is older than some cutoff, throw it away and start again:

const REBASE_AFTER_MS = 30 * 60_000;

function shouldRebase(snapshot: Snapshot): boolean {
  return Date.now() - snapshot.savedAt > REBASE_AFTER_MS;
}
Enter fullscreen mode Exit fullscreen mode

The reasoning behind the thirty minutes was that quitting and immediately reopening the app should still diff properly, so you do not miss anything in the gap. It sounds careful. It is wrong, and the reason is worth spelling out, because the same shape of mistake shows up in a lot of sync code.

The clock is not the thing that changed. The process is.

If the app was shut for four minutes, it was not watching for four minutes. Listings posted in that window have already been distributed by the site itself, by its own email alerts, by everyone else's tooling. Whether that window was four minutes or eleven hours does not change what the app can honestly claim. A threshold encodes a belief that a short gap is a gap you did not miss, and for this product that belief is simply false.

There is also the ordinary engineering objection: thirty minutes is a number nobody can defend. Why not twenty? Why not sixty? A rule you cannot justify at the boundary is a rule you will be re-tuning forever.

The rule we actually shipped

Re-base on the first check of each search after the app starts. Not after a timeout. Not after a threshold. After a start.

export class BaselineTracker {
  private checked = new Set<string>();

  /**
   * Record that this search is being checked, and say whether that is the
   * first time in this run of the app.
   */
  claimFirstCheck(url: string): boolean {
    if (this.checked.has(url)) return false;
    this.checked.add(url);
    return true;
  }

  seen(url: string): boolean {
    return this.checked.has(url);
  }

  /**
   * Forget a search, so its next check counts as the first again.
   * Used when a search is removed or re-pointed at a different URL.
   */
  forget(url: string): void {
    this.checked.delete(url);
  }
}
Enter fullscreen mode Exit fullscreen mode

That is the whole mechanism. A Set whose lifetime is the process lifetime. The moment the process ends, every search is new again, because the moment the process ends, the app genuinely stops knowing anything.

The decision itself is two booleans:

export function shouldRebase(hasStoredBaseline: boolean, firstCheckOfRun: boolean): boolean {
  return hasStoredBaseline && firstCheckOfRun;
}
Enter fullscreen mode Exit fullscreen mode

The hasStoredBaseline half is the case worth being explicit about. A brand new search has nothing to replace, so it is not "re-based", it just saves its first snapshot through the normal first-run path. Both paths end without an email. The difference is only what the activity log tells the user, and getting that wording right matters more than it sounds: "set a baseline for this search" and "skipping alerts, this is the first check since the app started" are answers to two different questions the user is asking.

The part that actually bites you

Here is the trap, and it is the reason this belongs in its own module.

A Set at module scope looks like something you should clean up. The monitor has a stop() function. stop() clears the live per-search run state, because "checking..." and "last checked 4s ago" describe a running monitor and are lies once it is not running. So stop() looks exactly like the place to clear the baseline tracker too.

It is not. Stopping the monitor is not restarting the app.

If you stop and start the monitor from inside the app, the process never died, the snapshots on disk are still accurate, and there is no reason on earth to throw away a perfectly good comparison and go quiet for a cycle. Same for all of these, which are the things users actually do all day:

Action Baseline kept?
Minimise the window Yes
Close the window to the system tray Yes
Machine goes to sleep and wakes Yes
Stop and restart the monitor in the app Yes
Quit the app and reopen it No, re-base

The rule that makes this easy to reason about: the tracker's lifetime is the answer. Do not write code that clears it. Let the process ending be the only thing that does, and every row of that table falls out for free rather than needing its own branch.

Two supporting decisions follow from that. The module imports nothing from Electron, so the behaviour can be exercised directly with pnpm test:baseline instead of being argued about in a review. And the tracker is held at module scope in the monitor rather than being constructed per run, so there is no lifecycle for anyone to accidentally shorten.

The trade we accepted

If the app crashes and restarts, it re-bases, and anything posted during the crash goes unreported. We took that deliberately. The alternative is a rule that tries to distinguish a crash from a quit, which means persisting a "was I shut down cleanly" flag, which is a new thing that can be wrong, in exchange for alerts we have already established are mostly stale anyway.

The honest framing for a product like this one: an alert you cannot act on is not a feature with low value, it is a feature with negative value. It trains people to stop opening your emails. Sending nothing is a real option and it should be on the table every time.

Go and look

If your product diffs anything against a saved snapshot, it is worth asking which of the two rules you are on. Most code defaults to the clock because the clock is easy to read. The question is almost always about the process.

Top comments (0)