Notifio is a desktop monitor for rental listings. Inside the Electron main process there is an Express server, the UI is a React app it serves over HTTP, and the renderer talks to it on 127.0.0.1 with no preload script and no IPC.
The live half of that is one server-sent events endpoint. It has an unusual constraint on it: the window reloads itself every single time it is opened from the tray. A fresh React tree, an empty store, no memory of the twelve hours the monitor spent working while the window was hidden.
A stream of changes is worthless to a client in that position. So the stream opens with the past:
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));
});
Up to 203 frames before the client is subscribed to anything: 200 log lines, then one status, one per-search snapshot, one notice. Then it joins the broadcast set.
The replay is a cap away from being a bug
The buffer is deliberately bounded on the server:
// In-memory log buffer (last 200 lines)
const LOG_BUFFER_SIZE = 200;
const logBuffer: string[] = [];
logBuffer.push(line);
if (logBuffer.length > LOG_BUFFER_SIZE) logBuffer.shift();
broadcast({ type: 'log', line });
And then, independently, in the renderer's reducer:
const MAX_LOG_LINES = 200;
// ...
case "log":
return {
...state,
logLines: [...state.logLines.slice(-(MAX_LOG_LINES - 1)), action.line],
};
Two caps, both 200, written in two files that do not import each other. Right now they agree, which means a fresh window's log pane is exactly the server's buffer: nothing trimmed, nothing missing. If I ever raise the server's buffer to 500, the client will silently throw away the first 300 frames it was just sent, and if I lower it to 50 the pane looks suspiciously short after a reload. Neither is a crash and neither is caught by a test. It is the sort of coupling that is fine while it is written down and dangerous when it is not, which is the main reason this paragraph exists.
Three snapshots rather than a patch stream
The two frames after the logs are whole-state, not deltas, and that is a choice I have been happy with:
// 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 }));
The reducer can therefore be careless in the good way:
case "runtime": {
// The monitor sends whole snapshots, so replacing the map wholesale is
// both correct and cheaper than merging entry by entry.
const runtime: Record<string, SiteRuntime> = {};
for (const entry of action.states) runtime[entry.url] = entry;
return { ...state, runtime };
}
Replay and live update are then the same code path. There is no "catch up" branch, no sequence number, no ordering requirement beyond "latest wins", and a reconnect in the middle of a poll cycle cannot leave a row describing a state the monitor is no longer in. The cost is bandwidth I do not care about: a few hundred bytes a few times a minute over loopback.
The status a client gets is not the status the monitor last said
This is the frame I got wrong first time, and the fix is four lines:
/**
* The status to report to a client that has just connected.
*
* A licence problem stops the monitor, so reporting a plain "stopped" whenever
* it is not running would throw away the only explanation of why it stopped,
* which is exactly what the user is looking at the window to find out.
*/
function reportedStatus(): string {
if (monitor.isRunning()) return lastStatus;
return lastStatus === 'license_invalid' || lastStatus === 'not_activated'
? lastStatus
: 'stopped';
}
A live stream can emit license_invalid as an event and the window that was open at the time will show it. A window that opens two hours later asks a different question: not "what just happened" but "what is the situation". Collapsing everything not running to stopped is right for a monitor the user stopped on purpose, and wrong for the one case where the app is the reason it is not running. One of those two needs saying out loud on connect, and it is the one that comes with a sentence in its own channel, which is what the third snapshot frame carries.
Two log lines that are not logs
The monitor has one log callback, and two prefixes get intercepted on the way through it:
monitor.onLog((line) => {
if (line.includes('__AUTH_FAILURE__:')) {
const url = line.split('__AUTH_FAILURE__:')[1].trim();
broadcast({ type: 'auth_failure', url });
return; // Don't add to visible log buffer
}
if (line.includes('__BLOCKED__:')) {
const url = line.split('__BLOCKED__:')[1].trim();
broadcast({ type: 'blocked', url });
return; // Don't add to visible log buffer
}
logBuffer.push(line);
// ...
});
Two event types that travel as log lines inside the main process and become structured events at the server boundary. The honest description is that this is a sentinel in a string channel, which I would not design on purpose, and which exists because the monitor already had one callback that reached the UI and these two facts needed to reach it before there was a second channel to carry them.
What saves it from being bad is the return. Neither line ever enters the buffer, so neither is replayed to a client that connects later, and that is correct: "your session for this host expired" is a fact about the present, and the renderer treats it as one.
case "auth_failure":
// The session is gone for the whole host, so every search on it needs
// the login prompt, not just the one that happened to notice.
dispatch({ type: "login", host: hostOf(msg.url), loggedIn: false });
break;
case "blocked":
// Already carried by the search's live state; nothing to store.
break;
One of them writes state, the other is deliberately a no-op because the snapshot frame already says it. If those two lines were in the replay buffer, a window opened tomorrow morning would ask you to sign in again because of something that happened last night and has since been fixed.
The client half is nine lines, and that is the point
export function useSSE(onMessage: (msg: SSEMessage) => void) {
useEffect(() => {
// Same-origin relative URL — works regardless of which port the server uses.
const es = new EventSource("/api/events");
es.onmessage = (e: MessageEvent) => {
try {
const msg = JSON.parse(e.data) as SSEMessage;
onMessage(msg);
} catch {
// ignore malformed messages
}
};
es.onerror = () => {
// EventSource auto-reconnects — nothing to do
};
return () => es.close();
}, []);
}
No reconnect logic, no backoff, no "connection lost" banner. EventSource reconnects by itself, and because the server replays everything on connect, a reconnect is indistinguishable from a first connect. The thing that makes the client allowed to be this boring is the 203 frames, not the browser API.
There is one gap I know about: there is no heartbeat frame. The endpoint sets Connection: keep-alive and then only writes when something happens, so a connection that dies silently is detected by the browser rather than by me. Over loopback, to a server in the same process as the window, I have never seen it matter. On a real network I would add a comment frame every 30 seconds and I would not think twice.
The other duplication worth admitting: the four replay writes build their own data: ...\n\n frames instead of going through the broadcast helper that formats every other frame. So the wire format is spelled out in two places in one file, five lines apart. It has not bitten me, and it is exactly the kind of thing that bites you the week you add a field.
And the window really does pay for all of this every time, because it also fires four REST reads on mount for the same state in parallel with the replay. Both paths carry whole snapshots and the reducer is last-write-wins, so the race has no wrong outcome, just duplicate work. That is a trade I made knowingly: the REST reads also fetch things the stream never sends, and 203 small frames over loopback are cheaper than the branch that would be needed to make the two paths exclusive.
The general lesson I would hand to anyone wiring a live stream into a client that can be destroyed and recreated at will: design the connect handshake first. If a client that has just arrived can render the complete current picture from the stream alone, you get reconnection, cold start and window reload for free from the same code. If it cannot, you will write three different recovery paths and one of them will be wrong.
You can watch the log pane this streams into by installing the app from the download page, what each panel shows is written out on the help page, it runs on a one-time licence, and the per-site setup guides, including the sign-in step behind those auth_failure events, are at notifio.app/alerts.
Top comments (0)