perf(web): a long-lived tab grows without bound (heap, DOM, CPU) — root causes and fix PRs
Symptom
A web tab of the app left open for hours (many sessions on the account, dozens of them streaming) gets slower and slower and eventually Chrome kills it (OOM). The native app on the same account is instant, so this is client-side, not server or network.
Chrome Performance Monitor on a self-hosted deployment (built from main):
| after ~5 min | after one night | |
|---|---|---|
| CPU | 99.9 % constant | 87.5 % |
| JS heap | ~520 MB | 1,210 MB |
| DOM nodes | 66k → 87k (stair-stepping up) | 587k |
| JS event listeners | 5.4k → 7.1k | 36k |
Follow-up of #1765 (main thread saturated on load); this issue is about what happens after load, over time and with the number of sessions.
What the heap says
A 3 GB .heapsnapshot of the overnight tab (29.2M nodes, 1.19 GB self size), analysed offline:
- 289k detached DOM nodes, retained by
system / Context / scopeclosures plusSVGAnimatedLength/SVGAnimatedTransformList/SMILTimeContainer. That is the signature of a react-native-reanimatedwithRepeat(-1)started in an effect with nocancelAnimationin the cleanup: the driver stays alive after unmount and, throughuseAnimatedStyle, keeps the unmounted subtree referenced. On webMaskedView+LinearGradientrender as an<svg><mask>, hence the SVG objects. - 145k live DOM nodes and 864k React
FiberNodes (≈430k mounted elements, current + alternate), 8.2M plain objects: the JS heap is dominated by the mounted React tree, not by synchronised data. A chat UI should be at a few thousand live nodes. - No
PerformanceObserveranywhere; the 61kPerformanceLongAnimationFrameTimingentries and the 208k accessibility-tree objects in the snapshot come from DevTools itself being open. Long-run measurements have to be taken with DevTools closed.
Root causes and fixes
A. Infinite animations never cancelled on unmount (detached DOM + CPU pinned)
StatusDotpulse, one per session row while thinking/permission: #1796ShimmerViewsweep (SVG mask on web): #1798VoiceBars(threeAnimated.loopwith no cleanup, JS driver on web = 3 rAF loops per unmount) and one shared driver for allShimmerTextinstances instead of one loop per thinking row: #1811
B. The web navigation stack keeps every opened chat mounted (live DOM + fibers)
@react-navigation/native-stack's webNativeStackViewrenders every route in the stack and only setsdisplay:noneon unfocused ones; everyrouter.pushof a session left the previousSessionViewalive: itsChatListpipeline kept running for its own session on every message,useSideChatSessionsran O(sessions) on every store commit, and each hiddenAgentInputkept document-levelpaste/droplisteners (a pasted image landed in every stacked composer). Fix: a singlesession/[id]route on web viadangerouslySingularkeyed on the route name (truekeys ongetSingularId(name, params), which fills in the id, so different sessions would not collapse) pluskey={sessionId}because the singular route reuses its key: #1805. Paste/drop ownership: #1808.
C. Per-message work that scales with the number of sessions (network, daemon, RAM)
handleUpdatecalledonSessionDataUpdatedfor every message of every session, even after the fast path had already applied it: a forward-sync GET that returned nothing, plus four git RPCs on the daemon (bypassing the 300 ms debounce), and for a never-opened session an initial load of 100 messages into the store. Fix: only the open chat, only when the fast path could not apply in order; git status through a live session of the project; unopened siblings still refresh a viewed project: #1801sessionMessagesentries were only ever removed bydeleteSession, so every session ever viewed (and, before #1801, every session that ever streamed) stayed in RAM with its messages held ~2×. Fix: LRU of the last 3 viewed chats, the rest released on leave and reloaded cleanly on re-entry: #1804applySessionsran on every socket event and on the 2 s activity flush, rebuildingSessionRowDatafor all N sessions and re-sorting both list views;applyMessagesre-minted theSessionobject on every message once usage existed (soSessionView→AgentInputre-rendered per message). Fix: skip rebuilds when no row-visible field changed, re-mint only on real todos/usage changes: #1802- Every tab refocus invalidated 11 syncs including a full
/v1/sessionsfetch + decrypt;exponentialBackoffDelaynever ramped (Math.max(count, maxFailureCount)), so during an outage each per-session sync retried at ~1 Hz forever; the reducer walked allcompletedRequestson every agent-state update: #1810
D. Render fan-out on every event
FlatSessionRowwasReact.memowithout a comparator on arowobject that was new on every rebuild, every row mounted a hiddenSessionActionsPopoverwith its own store subscriptions, anduseSessionActionAlert(native long-press only) subscribed on web too: #1807CommandPaletteProvidersubscribed to the wholesessionsmap and re-sorted it on every event;HomeDock/new/machineused the legacyuseSessions()array that changes identity on every event: #1809- In the open chat:
ChatListsubscribed to the whole session object but read three booleans; the render window only ever grew (after scrolling to the top of a 5000-message chat every later update ran O(5000) for the rest of the tab's life); the copy text of every completed turn in the window was re-joined on every message: #1803 - Code blocks re-tokenised on every hover, mermaid re-imported and re-rendered on every scroll back into view,
FilesSidebarrefetched git files on every daemon tick even with the panel closed: #1806
E. Lifecycle hygiene
- Attachment image cache capped by count (50) not bytes (up to ~650 MB of data URIs),
createObjectURLnever revoked, file panel polling a hidden tab every 5 s, a few stray timers and unbounded small caches: #1812
Status
- All 12 PRs are cherry-picked from one integration branch that has been running on a self-hosted web deployment since 2026-09-17: build,
pnpm typecheckand vitest clean (new tests in #1801, #1802, #1803, #1804, #1810, #1812 fail without their change), and the functional checks in each PR's test plan done by hand (list keeps updating, back from a chat goes to the list on web, paste lands in one composer, chats reopen with a clean load). - Quantitative before/after over days is still being collected on that deployment (Performance Monitor with DevTools otherwise closed, plus a heap snapshot at rest); I will post the numbers here.
- Suggested review order: #1805 (biggest single win for a long-lived tab), #1801, #1802 + #1804, #1807, then the rest; #1796 and #1798 are tiny and independent.
The per-row key that disables FlashList recycling in ChatList was left alone on purpose: rows carry local state (expanded diffs, collapsed output) and recycling would bleed it between messages.
Generated with Claude Code
Source: slopus/happy