Six polling loops and a spinning fan
VEX was idle and my laptop was audible. The fix was not "poll slower" — it was noticing that a UI which asks "anything new?" sixty times a minute is a UI with no idea what happened.
The bug report was a sound. VEX would be sitting there with nothing running — no batch, no agent, no edit in flight — and the fan would come up. Not loudly. Just enough that I noticed the room had changed.
Nothing was happening, and the machine disagreed.
What idle actually cost
An Electron app plus a Chrome extension, and between them six loops asking a local API whether anything had changed:
| Loop | Interval | Asking about |
|---|---|---|
| Projects page | 5s | the project list |
| Project detail — batches | 2s | batch status |
| Project detail — agents | 3s | agent registration |
| Activity page | 10s | the activity feed |
| Extension cursors | 3s | which agents are still alive |
| SDK log buffer | 0.5s | new lines in an in-memory list |
Individually all defensible. Together, at rest, that is a request every few hundred milliseconds, each one waking React, each re-render touching layout — forever, whether or not a single byte of state had changed.
The tell was that the number nobody could improve was the interval. Halve it and the UI feels stale; double it and the fan spins harder. That trade is a sign you are answering the wrong question. Polling asks “did anything change?” The system already knew the answer; it just had no way to say it.
The bit that was already there
VEX had NATS before this change. Every agent publishes its steps to
vex.agent.{id}.step, its status to vex.agent.{id}.status, cursor positions
to vex.batch.{id}.cursors. The whole live-agent experience — the cursors
parked on elements, the step-by-step trace — is push, and has been since the
batch work landed.
So the app was simultaneously the best-instrumented thing I have built and asking a database every two seconds whether a row had changed. Both, at once, in the same process.
What was missing was not transport. It was three subjects.
vex.project.events ← project created / updated / deleted
vex.batch.events ← batch submitted / processing / completed / failed
vex.activity.events ← anything worth a line in the activity feed
Note that these are broadcast subjects, and the existing ones are
per-entity. That distinction took me an embarrassing extra hour. The Projects
page does not want to know that project abc123 changed; it does not know
abc123 exists yet. It wants to know that something in the project list
changed. A page that must subscribe per entity has to enumerate the entities
first — which means fetching them — which is the thing we are trying to stop
doing on a timer.
Invalidate, do not apply
The obvious next move is to put the new state in the event payload and have the UI apply it. React state updated straight from the message: no HTTP at all.
I did not do that, and it is the decision in this change I am most sure about.
Every component already had fetchProjects(), fetchBatches(),
fetchActivity() — correct, tested, and the code path that runs on page load
anyway. Applying deltas would have meant a second way to arrive at state,
running only in the live case, diverging from the first the moment either
changed. Two paths to one truth is how you get a UI that is right after a
refresh and wrong before it.
So an event carries no data worth applying. It is a tap on the shoulder:
// event → "something over here changed" → re-run the fetch you already have
onProjectEvent(() => refetchProjects());
The round trip is a local HTTP call to a SQLite read. It costs nothing on the scale that matters, and it buys a system with exactly one way to compute what is on screen. The event decides when; the fetch still decides what.
Lesson: push is a scheduling improvement, not a state-transfer mechanism. The moment an event payload becomes authoritative, you own two implementations of your state and the slow one is the one that is correct.
Three things that broke immediately
Bursts. A batch of four agents does not emit four events, it emits dozens — statuses, steps, completions, all inside a second. Naive invalidation turns each into a fetch and a render, which is worse than the polling it replaced. Every subscription is debounced at 300ms per data source. Long enough to collapse a burst, short enough that nobody perceives it. Debounce, not throttle — after a burst the only state anyone cares about is the last one.
Disconnects. NATS events are not persisted here, deliberately: JetStream for a single-user local app is operational weight for a problem that does not exist. But events during a disconnect are gone, and a UI that reconnects into silence stays wrong indefinitely. So reconnect triggers a full refresh of everything subscribed. The recovery path for “I missed some events” is the load path, which runs on every cold start and is therefore the best-tested code in the app.
Cold loads. Events supplement the initial fetch, they never replace it. Obvious in hindsight; not obvious at 1am when the Projects page is empty because you deleted the fetch that filled it.
What is still polling
Three loops survived, and I want them written down rather than quietly tolerated:
| Loop | Now | Why it stays |
|---|---|---|
| Dev server logs | 1s → 3s | Reads an Electron child process’s stdout. There is no backend to publish an event. |
| Health check | 4s | The question is “is the connection down?” An event cannot tell you that; its absence is the signal, and absence has no timestamp. |
| Cursor position | 200ms → 500ms | Pure client-side DOM layout tracking. Nothing crosses a process boundary. |
Plus the SDK log-buffer loop, slowed from asyncio.sleep(0.5) to 1.5 — a
busy-wait over an in-memory list, three times fewer wake-ups for logs that
nobody reads at sub-second resolution.
The health check is the interesting one. Polling is genuinely correct there, because the fact being observed is the absence of communication. You cannot be notified that a notification will not arrive.
Lesson: “we removed all polling” is nearly always a lie, and an architecture diagram that claims it is hiding a loop. Six went; three stayed and got slower. The useful discipline is knowing which is which and why.
The part I would tell my past self
The fan was a real signal and I ignored it for weeks, because the app was correct the whole time. Every number on screen was right within a few seconds, every test passed, no user-visible bug existed. The thing being wasted was somebody’s battery, which no assertion in the suite has an opinion about.
Idle cost is a design property. If your system burns measurable power to discover that nothing happened, that is not tuning — the components have no way to tell each other anything, and the timer is the workaround.
The room is quiet now.