DEV Community

Daniel Pertu
Daniel Pertu

Posted on

The window reloads every time you open it, so it cannot own the state

Notifio is an Electron app that watches rental search pages and tells you the second something new is posted. It lives in the system tray, because a rental alert tool you have to keep on screen is a rental alert tool you close by Tuesday.

That tray behaviour produced a bug I did not see coming: the window reloads itself every time it is re-shown. Hide it, click the tray icon, and the renderer mounts from scratch. Everything React knew is gone.

For a long time the renderer was where the per-search state lived. It was assembled from a login-status probe on mount plus whatever server-sent events happened to arrive while the window was open. Which means anything that happened while the window was hidden was simply lost, and hidden is the normal case for this app.

The symptom the user sees

You open the window. Every search says "unknown". The app has been running for six hours, has checked each search seven hundred times, and has nothing to say about any of it.

Worse, it is not even a blank slate. A search that was mid-check when you last looked can come back as "checking..." forever, because the message that would have cleared it arrived while nothing was listening.

The rule this ended up teaching me

Whoever performs the work owns the record of the work. Not whoever displays it.

The monitor is the only thing that actually knows what happened on the last check of each search. So the monitor records it, in the main process, and the renderer is a reader:

export interface SiteRuntime {
  url: string;
  state: 'unknown' | 'checking' | 'ok' | 'blocked' | 'auth_failure' | 'error';
  lastCheckedAt: number | null;
  lastDurationMs: number | null;
  listingCount: number | null;
  lastNewCount: number;
  newTotal: number;
  lastNewAt: number | null;
  /** Set while the search's host is waiting out a retry, so the UI can count down. */
  nextRetryAt: number | null;
  consecutiveFailures: number;
  message: string | null;
  checks: number;
}
Enter fullscreen mode Exit fullscreen mode

One Map<string, SiteRuntime> in the main process, one writer, and a handful of named transitions rather than arbitrary patches from anywhere:

export function markOk(url: string, data: {
  listingCount: number; newCount: number; durationMs?: number; message?: string;
}): void {
  const previous = get(url);
  patch(url, {
    state: 'ok',
    lastCheckedAt: Date.now(),
    listingCount: data.listingCount,
    lastNewCount: data.newCount,
    newTotal: previous.newTotal + data.newCount,
    lastNewAt: data.newCount > 0 ? Date.now() : previous.lastNewAt,
    nextRetryAt: null,
    consecutiveFailures: 0,
    checks: previous.checks + 1,
  });
}
Enter fullscreen mode Exit fullscreen mode

markChecking, markOk, markFailure, setNextRetry, forget, reset. That is the whole surface. The point of naming them is that "a check succeeded" and "a check succeeded but found nothing" are the same transition with different numbers, and a free-form patch API invites callers to invent a third spelling of it.

In memory on purpose

This state is never written to disk, and that is a decision rather than laziness.

It describes the current run. A checking... restored from a file after a restart is a lie about something happening right now, and lastCheckedAt: 3 days ago is a worse answer than "not checked yet this session", because it invites the user to believe the app was watching while it was closed. It was not. It was closed.

So the app starts empty and fills in within one cycle, which takes about thirty seconds.

Snapshots, not patches

The transport is server-sent events from a loopback Express server inside the app. The obvious design is to stream deltas. I stream the whole thing instead:

monitor.onSiteState((sites) => broadcast({ type: 'sites', sites }));
Enter fullscreen mode Exit fullscreen mode

A snapshot for fifteen searches is a few hundred bytes and arrives at most a few times a cycle. Incremental patches would save almost nothing and would introduce the one failure mode that is genuinely hard to debug: a renderer whose state is wrong because it missed or misordered a patch three hours ago. With whole snapshots, the worst case for a dropped frame is that the UI is stale for a second.

The same decision makes reconnection trivial. When a client connects, it gets the current picture replayed before it gets anything live:

app.get('/api/events', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.flushHeaders();

  logBuffer.forEach((line) => res.write(frame({ type: 'log', line })));
  res.write(frame({ type: 'status', status: reportedStatus() }));
  res.write(frame({ type: 'sites', sites: monitor.siteStates() }));
  res.write(frame({ type: 'notice', notice: monitor.getNotice() }));

  sseClients.add(res);
  req.on('close', () => sseClients.delete(res));
});
Enter fullscreen mode Exit fullscreen mode

The initial fetch and the live stream are the same message type, built from the same function. There is no "load" path that can disagree with the "update" path, because there is only one path. The renderer's entire reconnection logic is that EventSource reconnects by itself and it does not have to care:

es.onerror = () => {
  // EventSource auto-reconnects, nothing to do.
};
Enter fullscreen mode Exit fullscreen mode

One thing I had to add back

Changes arrive in bursts. A poll cycle touches every search in turn, so emitting on each write pushes a stream of near identical snapshots at a window that may not even be visible. They get coalesced into one frame:

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);
  // Do not hold the process open for a pending UI update.
  _emitTimer.unref?.();
}
Enter fullscreen mode Exit fullscreen mode

Two details in there I would keep in any app of this shape. The listener loop swallows listener errors, because a UI subscriber throwing must never be able to take down a background poll. And the timer is unreffed, so a pending repaint cannot keep the process alive after everything else has shut down.

What it looks like now

Every search shows its last check time, how long that check took, how many listings the page held, how many were new, and, when its host is waiting out a retry, a live countdown to the next attempt. Hide the window for four hours and re-open it, and it is all still there, because none of it was ever the window's to lose.

You can see the monitoring UI, and what each row reports, on the help page. The app itself is on notifio.app/download for Mac and Windows, and the list of sites it watches is at notifio.app/alerts.

The general version of this, for any app with a background worker and a window that can go away: if closing the UI loses information, the information was in the wrong process.

Top comments (0)