Notifio is an Electron app that lives in the system tray, and its window reloads itself from scratch every time you re-show it. The renderer talks to an Express server running in the main process, and the live channel between them is one server-sent events stream at /api/events.
In a normal web app, a client connecting is a rare event that happens once per page view. Here it is the most common event in the system. Somebody clicking the tray icon twenty times a day produces twenty fresh connections, each of which needs to render six hours of accumulated reality instantly.
Which turns the connect handler into the most important thirty lines in the server.
The handler
app.get('/api/events', (req: Request, res: Response) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
// 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`);
sseClients.add(res);
req.on('close', () => sseClients.delete(res));
});
Four kinds of frame go out before the connection is added to the broadcast set: the recent log, the monitor's status, the per-search runtime state, and the notice explaining that status if there is one.
The ordering matters. Replay happens before sseClients.add(res), so a live event that fires mid-replay cannot overtake the snapshot it belongs to and be overwritten by older state a millisecond later. Add first and you have a race that reproduces roughly never on a dev machine and occasionally on a user's.
Why replay instead of fetch-then-subscribe
The conventional shape is: the component mounts, fetches the current state over REST, then opens a stream for updates. Notifio did that, and it has a gap in the middle. Anything that happens between the fetch resolving and the stream connecting is not in either.
For a monitor checking fifteen searches on a thirty second cycle, the odds of something landing in that window are not small. The symptom is a search stuck on "checking..." forever, because the message that would have cleared it arrived while nothing was listening.
Replaying on the stream itself closes the gap, because the snapshot and the subscription are the same act, ordered by one server. It also deletes the loading state from the UI, deletes three REST calls from mount, and means that "what does a client know" has exactly one answer no matter how it got here.
"Stopped" is not always the answer, even when it is stopped
This is my favourite four lines in the file:
function reportedStatus(): string {
if (monitor.isRunning()) return lastStatus;
return lastStatus === 'license_invalid' || lastStatus === 'not_activated'
? lastStatus
: 'stopped';
}
A licence problem stops the monitor. So if you report "not running" as a flat stopped, you have thrown away the only explanation of why, and the user is looking at the window precisely because they want that explanation.
The naive version of this handler reports monitor.isRunning() ? 'running' : 'stopped', which is technically accurate and useless. The distinction it misses is that a state and the reason for it are different facts, and a reconnect must not lose the second one.
The notice frame is the same idea taken further: a short sentence that goes with the status, for the cases where one word is not enough. It gets replayed too, and it is replayed as monitor.getNotice() rather than from a remembered string, so the client is told what is true now, not what was announced at the moment it happened.
A bounded log is a design decision, not a memory optimisation
const LOG_BUFFER_SIZE = 200;
const logBuffer: string[] = [];
Two hundred lines is roughly the last few polling cycles. Enough to answer "what has it been doing", short enough that replaying it into a freshly opened window is instant.
The interesting part is the failure mode you get by making it bigger. A user who leaves the app running for a week and then opens the window gets every line since Monday pushed down a stream before the first useful frame arrives. The buffer is not there to save memory, it is there to bound how long replay takes, and that bound is the thing a user actually experiences.
Broadcast has to survive a client that has already left
function broadcast(payload: unknown): void {
const frame = `data: ${JSON.stringify(payload)}\n\n`;
sseClients.forEach((res) => {
try {
res.write(frame);
} catch {
// Client vanished mid-write; the close handler will drop it.
}
});
}
A tray app is closed abruptly and often. There is a real window between the socket dying and req.on('close') firing, and a throw inside that forEach takes down the poll that was trying to tell everyone about a new listing.
Swallowing the error is right here specifically because there is nothing to do about it: the close handler is already going to remove this client, and the payload is about to be replaced by a newer one anyway. Every broadcast frame in this system is either idempotent or superseded within seconds, which is what makes a silent catch defensible rather than lazy.
Bursts are coalesced before they reach the wire
One poll touches every search in turn, so state changes arrive in clusters. Emitting a frame per change would push a stream of near-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 { /* ... */ } });
}, EMIT_DEBOUNCE_MS);
// Don't hold the process open for a pending UI update.
_emitTimer.unref?.();
}
Leading-edge guard rather than trailing-edge reset, so a long burst still produces a frame every 120ms instead of one frame after the burst finally stops. The user sees progress during a cycle, not a single update at the end of it.
The unref() is the kind of detail that only matters in a desktop app: a pending timer for a UI update is not a reason to keep a process alive during shutdown.
The payoff is on the client
Because the server tells a new connection everything, the client-side hook is this, in full:
export function useSSE(onMessage: (msg: SSEMessage) => void) {
useEffect(() => {
const es = new EventSource("/api/events");
es.onmessage = (e: MessageEvent) => {
try {
onMessage(JSON.parse(e.data) as SSEMessage);
} catch {
// ignore malformed messages
}
};
es.onerror = () => {
// EventSource auto-reconnects, nothing to do
};
return () => es.close();
}, []);
}
No reconnection logic, no backoff, no resync on reconnect, no "am I stale" check. EventSource reconnects on its own, and reconnecting is the same thing as connecting, which the server already handles completely.
That is the actual reason to put the work in the connect handler. Every line of replay on the server deletes several lines of state reconciliation on the client, and client-side reconciliation is where the bugs you cannot reproduce live.
Notifio is the app: a desktop monitor that watches rental search pages and tells you the second something new is posted, rather than waiting for a portal's email to work its way through a send queue. Installers are at notifio.app/download, the per-site detail is at /alerts, and there is a walkthrough at /help. The architectural backstory to this post is the window reloads every time you open it, so it cannot own the state.
Top comments (0)