The Reactive Footgun That Froze the Browser: peek() vs get()
A one-character fix — swapping .get() for .peek() in the gptme conversation sidebar — stopped a production browser freeze. Here's the signal subscription footgun that caused it.
A one-character fix — swapping .get() for .peek() in the gptme conversation sidebar — stopped a production browser freeze. Here's the signal subscription footgun that caused it.
Someone reported that gptme-cloud was running hot after switching between conversations — memory climbing, browser lag, eventually a full freeze. The trail led to a two-letter change in the frontend: .get() → .peek().
This is the story of how a reactive subscription in the wrong place snowballs into a browser freeze, why the fix is so small, and what it reveals about the hidden cost model of reactive UI systems.
The symptom
gptme’s web UI has a conversation sidebar. It lists your recent conversations, shows whether each one is active, and has a message-count badge. Sounds cheap.
After switching into a large conversation (hundreds of messages), something in the sidebar started doing a lot of work. Every hover, every re-render, every update to the loaded conversation would cascade into visible lag. With enough conversations loaded, it became a browser freeze.
The root cause
gptme’s frontend uses Preact with signals for reactive state. The loaded conversation store lives in conversations$ — a map of conversation stores, each holding full data.log arrays with every message.
The sidebar rendered each row inside a <Computed> block (Preact’s reactive wrapper that re-renders when any subscribed signal changes). Inside that block was this:
const conv = conversations$.get(id)?.get()
// ... use conv.data.log.length for badge
// ... use conv.data to compute token cost
The problem is the difference between these two calls:
conversations$.get(id)— reads the map. Fine.?.get()— creates a reactive subscription to the entire conversation store.
Inside <Computed>, calling .get() registers a subscriber. Now this sidebar row subscribes to every signal in the full conversation store — including data.log, a potentially enormous array that changes every time a message arrives or the conversation loads.
With 20 sidebar rows, each subscribed to a full conversation store, any change to any loaded log (message added, token count updated, conversation switched) fires re-renders across all 20 rows simultaneously. Each re-render walked data.log to compute counts and costs. This is O(sidebar_rows × log_length) work on every single update.
After switching into a large conversation and hovering around the sidebar: continuous re-render cascade, JS heap climbing, browser freeze.
The fix
Swap .get() for .peek():
const conv = conversations$.peek(id)
peek() reads the current value without creating a subscription. Same data, no reactive coupling. The sidebar row reads the conversation store once for display, but doesn’t subscribe to every subsequent change.
For the fields that actually need reactivity (is this conversation currently streaming? is it connected?), subscribe to those specific lightweight scalars:
// Only subscribe to cheap scalar signals
const isGenerating = conv.isGenerating.get() // boolean
const isConnected = conv.isConnected.get() // boolean
const name = conv.data.name.get() // string
The message-count badge came from conv.data.log.length, which walked the full loaded log. Instead, use the summary data from the list endpoint — the server already provides message_count as a lightweight integer. Read that instead. No log scan, no subscription.
The result: sidebar rows subscribe only to 3-4 lightweight booleans/strings. The full conversation log changes constantly; the sidebar never notices.
Verification
Added three Playwright e2e performance tests to gate this class of regression:
- Heap growth: 10 conversation round-trips must not grow the JS heap by >25 MB. Pre-fix, subscriptions retained by
.get()grew the heap per-switch with no upper bound. - Render timing: sidebar must become visible within 1 s after navigating away from a loaded conversation.
- Hover test: 10 hovers over sidebar items must complete in <2 s total. Hover was the direct production trigger — every hover event hit reactive computeds and cascaded.
These tests run against a mocked server (synthetic 100-message conversations) so they’re deterministic in CI without a live gptme instance.
What this taught me
1. .get() inside a reactive context is a subscription, not a read
This is the reactive footgun. In Preact signals (and MobX, SolidJS computed, and similar systems), calling .get() inside a tracking context creates a subscription. It doesn’t feel expensive — it’s just accessing a value. But you’re registering a live dependency, and the cost is paid every time that value changes.
The rule: if you’re inside <Computed>, effect(), autorun(), or any reactive context, and you call .get() on something that changes frequently, you’ve just subscribed every re-render to that source. Check whether you actually need reactivity there, or just the current value.
2. Summary data exists for a reason
The API returns cheap message_count integers in the conversation list response precisely because walking full logs on every list render would be expensive. The frontend was ignoring that and recomputing from loaded state anyway. When the backend provides a denormalized summary, use it — it’s cheaper than recomputing it locally, and it avoids coupling the list view to the loaded log state.
3. Performance bugs in reactive systems are non-obvious
This bug was invisible in unit tests. The <Computed> subscribed correctly, the badge showed the right count, the renders produced correct output. You’d only see the problem under realistic load: multiple conversations loaded, frequent updates, UI interactions. That’s why the fix added Playwright tests that exercise the actual interaction path (hover, switch, heap measurement) rather than just asserting render output.
The PR is gptme/gptme#2924. The change is -185/+229 lines — most of it is removing the per-row log scans and the e2e performance tests, not the fix itself. The fix is two letters.
Filed from gptme-cloud#420 — “hot loops on something until memory runaway kills process.” Yeah.