Notifio is an Electron app that watches up to fifteen rental search pages and emails you when a listing appears that was not there last time. The window shows a card per search: what it is doing, when it last ran, how long it took, how many listings it saw, how many of those were new.
All of that is knowledge the main process has and the renderer does not, and the way it gets across has been rewritten twice. This post is about the second rewrite, which was not about where the state lives but about how often it is allowed to be announced.
I covered the storage decision separately in A stale "checking..." restored from disk is a lie, so one of our six stores has no file. Short version: this one store is in memory only, deliberately. This post is the transport.
What it replaced
/**
* Live per-search state, owned by the main process.
*
* Before this existed the renderer kept its own idea of what each search was
* doing, assembled from a login-status probe on mount and whatever SSE messages
* happened to arrive while the window was open. Anything the user missed while
* the window was hidden was simply lost, and the window reloads itself every
* time it is re-shown, so "lost" was the normal case.
*/
That last clause is the part that makes this an Electron problem rather than a general web problem. The window reloads on every re-show, which I wrote about in Our window reloads itself every time you open it, so the renderer may not remember anything. A renderer that derives state from the event stream it happened to witness is a renderer that knows nothing, because it was not there.
So the main process records it instead, in one map:
const _states = new Map<string, SiteRuntime>();
Twelve fields per search, six possible run states (unknown, checking, ok, blocked, auth_failure, error), and a default:
function blank(url: string): SiteRuntime {
return {
url,
state: 'unknown',
lastCheckedAt: null,
// ...
checks: 0,
};
}
export function get(url: string): SiteRuntime {
return _states.get(url) ?? blank(url);
}
get never returns undefined, which means every writer can be a merge over the current value without first asking whether there is one:
export function patch(url: string, changes: Partial<Omit<SiteRuntime, 'url'>>): SiteRuntime {
const next: SiteRuntime = { ...get(url), ...changes, url };
_states.set(url, next);
scheduleEmit();
return next;
}
Note url is spread in last. It is the map key, so a caller cannot accidentally rename the thing it is describing, and the type already forbids passing it.
The part that needed fixing
Every write calls scheduleEmit, and the naive version of that function pushes a snapshot at the renderer. That is fine for one write. It is not fine for how the poll loop actually behaves:
// Changes arrive in bursts (a poll touches every search in turn). Emitting on
// each one would push a stream of near-identical snapshots at the renderer, so
// they are coalesced into one frame.
const EMIT_DEBOUNCE_MS = 120;
let _emitTimer: NodeJS.Timeout | null = null;
Notifio scrapes searches sequentially, and each search produces at least two writes: one when the check starts, one when it finishes. Fifteen searches is thirty writes per cycle, and because the snapshot is the whole array, each one serialises the state of every other search as well. Thirty near-identical arrays of fifteen objects, sent down an event stream, parsed in the renderer, and handed to React as a new array identity each time.
The debounce is leading-edge-suppressing rather than trailing:
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 things in nine lines are worth pulling out.
The snapshot is taken when the timer fires, not when the write happened. So the frame the renderer gets is current as of the emit, not a replay of the state at the moment some earlier write queued the timer. Coalescing by dropping intermediate values is only correct because the payload is a full snapshot rather than a diff; a diff stream would have to be queued and replayed in order.
unref, with an optional call. A pending UI update is not a reason for Node to stay alive, and the optional call is there because this module is also loaded in contexts where the timer handle is a browser-style number without unref.
The try/catch around each listener. The emitter is called from inside the poll loop. A listener that throws would otherwise take the poll down with it, and the thing a listener does is write to an HTTP response that may have gone away. A dead window must not be able to stop monitoring.
The same instinct shows up in the one writer that can be called redundantly:
/** Update only the retry clock, for searches waiting behind another one's block. */
export function setNextRetry(url: string, nextRetryAt: number | null): void {
if (get(url).nextRetryAt === nextRetryAt) return;
patch(url, { nextRetryAt });
}
Searches sitting behind a sibling's block get their retry clock written every cycle whether it changed or not. The early return means an unchanged value does not schedule a frame at all, rather than relying on the debounce to absorb it.
Two readers, one shape
The thing that makes the whole arrangement cheap is that the initial fetch and the live stream return the same object. There is no "initial state" DTO and no separate hydration shape.
The HTTP route:
app.get('/api/monitor/status', (req: Request, res: Response) => {
res.json({
running: monitor.isRunning(),
polling: monitor.isPolling(),
status: reportedStatus(),
sites: monitor.siteStates(),
notice: monitor.getNotice(),
});
});
And the event stream, which replays rather than waiting for the next change:
// Replay enough for a client that has just connected (or reconnected after
// the window was hidden) to render the full picture without extra requests.
logBuffer.forEach((line) => res.write(`data: ${JSON.stringify({ type: 'log', line })}\n\n`));
res.write(`data: ${JSON.stringify({ type: 'status', status: reportedStatus() })}\n\n`);
res.write(`data: ${JSON.stringify({ type: 'sites', sites: monitor.siteStates() })}\n\n`);
res.write(`data: ${JSON.stringify({ type: 'notice', notice: monitor.getNotice() })}\n\n`);
The last two hundred log lines, then three state frames, all before the connection is added to the client set. A freshly reloaded window is fully painted off the stream alone and makes no second request. monitor.siteStates() there is one line delegating to snapshot(), which is Array.from(_states.values()).
The renderer side is almost nothing, because EventSource handles reconnection itself:
const es = new EventSource("/api/events");
The error handler is an empty body with a comment saying so, because EventSource reconnects on its own and the replay above means a reconnect is also a full re-render. There is nothing for the renderer to do about a dropped stream.
The URL is relative on purpose: the local API binds to whichever port is free, so a hardcoded origin would be a bug waiting for a port collision.
The clock is a separate problem
Coalescing solves pushing. It does not solve the other source of pointless renders, which is that "last checked 40s ago" and "retrying in 2m 10s" both go stale without any state changing at all.
/**
* A clock that re-renders on an interval, so relative times and countdowns
* stay honest without every component owning a timer.
*
* Pass `active: false` when nothing on screen is counting down: a monitor
* sitting idle overnight should not be re-rendering once a second.
*/
export function useNow(intervalMs = 1000, active = true): number {
Three call sites, three different intervals, three different conditions:
useNow(5000, isActive);
useNow(1000, hasCountdown || app.status === "polling");
useNow(30_000, app.finds.length > 0);
A second is only needed where there is an actual countdown ticking. The recent finds list shows relative timestamps measured in minutes, so thirty seconds is plenty, and it stops entirely when the list is empty. This is a long-running tray app, so an idle window waking the renderer sixty times a minute to recompute identical strings is a battery cost for nothing.
Go and look
The app is on the download page for macOS and Windows, the help page describes what the per-search states mean in user terms, and the supported sites pages explain which sites need a logged in session, which is the condition behind the auth_failure state above. Facebook Marketplace is the one where you will actually see it.
Top comments (0)