Notifio is a tray app. It watches rental search pages in the background, and the window is something you open for ten seconds to see whether anything is happening, then close again. Closing it does not quit:
mainWindow.on('close', (event) => {
if (!(app as any).isQuitting) {
event.preventDefault();
mainWindow?.hide();
}
});
And there is one more line near it that quietly dictated the architecture of the entire front end:
// Reload the UI when the window is re-shown after being hidden to prevent
// black screen and stale state (e.g. searches disappearing)
mainWindow.on('show', () => {
mainWindow?.webContents.reload();
});
Every time the user opens the window, the renderer is reloaded from scratch. Which means the renderer's state is destroyed on a schedule set by somebody else's tray clicks.
What that broke
Before this was properly accounted for, the renderer built its own picture of what each search was doing. On mount it probed login status for each site. After that it updated itself from whatever server-sent events happened to arrive while the window was open.
Read that back with the reload in mind. The renderer's knowledge was the union of one probe and the events that arrived during a window of time that the user controls and usually keeps very short. Everything that happened while the window was hidden was simply gone, and for a monitoring app, hidden is the normal state.
The symptom users saw was a window that looked like nothing had happened all morning.
The fix is that the main process answers the question
The monitor is the only thing that actually knows what each search is doing, so it now records it, in a module whose entire job is to hold that:
export type SiteRunState =
/** Not checked yet this session. */
| 'unknown'
/** Being scraped right now. */
| 'checking'
/** Last check succeeded. */
| 'ok'
/** Last check hit an anti-bot wall. */
| 'blocked'
/** Last check landed on a login page. */
| 'auth_failure'
/** Last check threw (network, browser crash). */
| 'error';
With per-search facts hung off it:
export interface SiteRuntime {
url: string;
state: SiteRunState;
lastCheckedAt: number | null;
lastDurationMs: number | null;
listingCount: number | null;
lastNewCount: number;
newTotal: number;
lastNewAt: number | null;
/** Set while the search's host is in backoff, so the UI can count down. */
nextRetryAt: number | null;
consecutiveFailures: number;
message: string | null;
/** Successful checks this session, used to tell "quiet" from "never ran". */
checks: number;
}
That last field is my favourite thing in the file. "Nothing found" and "never actually ran" look identical in a UI that only reports listing counts, and they mean completely different things to somebody deciding whether to trust the app. One count makes them distinguishable.
It is in memory, and that is also deliberate
const _states = new Map<string, SiteRuntime>();
No disk. The module's doc comment says why:
Deliberately in memory only: it describes the current run, and a stale
"checking..." restored from disk after a restart would be a lie.
This is the same instinct as the rule that governs alerting in this app, where the first check after a restart is not allowed to tell you anything. State that describes a process is only true while that process is alive. Persisting it converts a fact into a confident lie that outlives the thing it described.
The broader map of which state in this app is allowed to survive what is in Four places state lives in our Electron app, sorted by when it is allowed to die. What this post adds is the window reload that forces the question in the first place.
One snapshot, two delivery routes
Because the renderer is reloaded rather than reinitialised, the same picture has to be available two ways: as a reply to a request, and as a push.
So it is the same function in both. On a normal fetch:
app.get('/api/monitor/status', (req, res) => {
res.json({
running: monitor.isRunning(),
polling: monitor.isPolling(),
status: reportedStatus(),
sites: monitor.siteStates(),
notice: monitor.getNotice(),
});
});
And in the first frames every SSE client is sent the instant it connects:
res.write(`data: ${JSON.stringify({ type: 'sites', sites: monitor.siteStates() })}\n\n`);
A reload is then not a special case at all. It is a new subscriber, and a new subscriber is told everything. I wrote about what else is in that opening burst in Four frames before anything happens; this is the piece of it that exists because the window keeps throwing itself away.
One detail in there worth stealing. reportedStatus() does not report a bare "stopped" when the monitor is not running:
function reportedStatus(): string {
if (monitor.isRunning()) return lastStatus;
return lastStatus === 'license_invalid' || lastStatus === 'not_activated'
? lastStatus
: 'stopped';
}
A licence problem stops the monitor. Collapsing that into "stopped" throws away the only explanation of why, which is precisely what the user opened the window to find out.
Snapshots, not patches
// Per-search state is owned by the monitor and streamed as whole snapshots.
// A snapshot is a few hundred bytes and arrives at most a few times a cycle,
// which is far simpler to reason about than a set of incremental patches the
// renderer would have to apply in order.
monitor.onSiteState((sites) => broadcast({ type: 'sites', sites }));
Incremental patches are the kind of optimisation that is free until the first dropped message, and then costs you a week. A client that receives whole snapshots cannot drift, cannot apply them out of order, and does not need to be reconciled after a reconnect. Fifteen searches of a dozen small fields is not a payload worth being clever about.
The one optimisation that is there
A poll cycle touches every search in turn, so state changes do not trickle, they arrive in bursts. Emitting on each one would push a stream of nearly identical snapshots at a renderer that is going to paint once:
const EMIT_DEBOUNCE_MS = 120;
function scheduleEmit(): void {
if (_emitTimer) return;
_emitTimer = setTimeout(() => {
_emitTimer = null;
const snap = snapshot();
_listeners.forEach((fn) => {
try {
fn(snap);
} catch {
/* a broken listener must not stop the poll */
}
});
}, EMIT_DEBOUNCE_MS);
// Don't hold the process open for a pending UI update.
_emitTimer.unref?.();
}
Three small things, all of which I would want in any code like this:
The leading if (_emitTimer) return makes this a trailing-edge coalescer rather than a queue of timers. The whole burst becomes one frame.
unref() means a pending UI update is not a reason the process stays alive. A 120ms timer holding up a quit is a bug you will chase for an hour and feel stupid about.
And the listener loop swallows. A UI subscriber throwing must not take down a poll cycle whose actual job is finding somebody a flat.
There is one more guard, on the writer side:
export function setNextRetry(url: string, nextRetryAt: number | null): void {
if (get(url).nextRetryAt === nextRetryAt) return;
patch(url, { nextRetryAt });
}
When one site goes into backoff, every search on that host is waiting behind it, and the monitor will happily set the same retry timestamp on all of them on every pass. Comparing before writing turns that into zero emits instead of a snapshot per pass.
The rule worth taking away
Decide who owns a piece of state by asking what event destroys it. Our renderer is destroyed by a tray click, which is an event the user generates casually and often, so the renderer cannot own anything that matters. Our main process is destroyed by quitting the app, which is exactly the lifetime of the facts in this module, so it owns them and nothing is written to disk.
If you have a UI that can be torn down outside its own control, whether that is a tray reload, a mobile OS reclaiming a background activity, or a web app someone reloads mid-flow, the question is not "how do I persist my component state". It is "which process has the right lifetime to be the owner".
See it working
Install it from notifio.app/download, add a search, let it run, then close the window and open it again from the tray. The per-search rows come back with their check counts, timings and retry countdowns intact, because none of that was ever the window's to remember.
What those states mean, including what a blocked site looks like and what to do about it, is on notifio.app/help. The per-portal pages under notifio.app/alerts cover which sites tend to produce which of them, and notifio.app/for/students is the version for the people who leave this thing running for three weeks straight.
Top comments (0)