#1813·happy

perf(web): a long-lived tab grows without bound (heap, DOM, CPU) — root causes and fix PRs

Author: f-livaCreated Sep 17, 2026Updated Sep 17, 2026

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 / scope closures plus SVGAnimatedLength / SVGAnimatedTransformList / SMILTimeContainer. That is the signature of a react-native-reanimated withRepeat(-1) started in an effect with no cancelAnimation in the cleanup: the driver stays alive after unmount and, through useAnimatedStyle, keeps the unmounted subtree referenced. On web MaskedView + LinearGradient render 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 PerformanceObserver anywhere; the 61k PerformanceLongAnimationFrameTiming entries 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)

  • StatusDot pulse, one per session row while thinking/permission: #1796
  • ShimmerView sweep (SVG mask on web): #1798
  • VoiceBars (three Animated.loop with no cleanup, JS driver on web = 3 rAF loops per unmount) and one shared driver for all ShimmerText instances 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 web NativeStackView renders every route in the stack and only sets display:none on unfocused ones; every router.push of a session left the previous SessionView alive: its ChatList pipeline kept running for its own session on every message, useSideChatSessions ran O(sessions) on every store commit, and each hidden AgentInput kept document-level paste/drop listeners (a pasted image landed in every stacked composer). Fix: a single session/[id] route on web via dangerouslySingular keyed on the route name (true keys on getSingularId(name, params), which fills in the id, so different sessions would not collapse) plus key={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)

  • handleUpdate called onSessionDataUpdated for 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: #1801
  • sessionMessages entries were only ever removed by deleteSession, 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: #1804
  • applySessions ran on every socket event and on the 2 s activity flush, rebuilding SessionRowData for all N sessions and re-sorting both list views; applyMessages re-minted the Session object on every message once usage existed (so SessionViewAgentInput re-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/sessions fetch + decrypt; exponentialBackoffDelay never ramped (Math.max(count, maxFailureCount)), so during an outage each per-session sync retried at ~1 Hz forever; the reducer walked all completedRequests on every agent-state update: #1810

D. Render fan-out on every event

  • FlatSessionRow was React.memo without a comparator on a row object that was new on every rebuild, every row mounted a hidden SessionActionsPopover with its own store subscriptions, and useSessionActionAlert (native long-press only) subscribed on web too: #1807
  • CommandPaletteProvider subscribed to the whole sessions map and re-sorted it on every event; HomeDock / new / machine used the legacy useSessions() array that changes identity on every event: #1809
  • In the open chat: ChatList subscribed 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, FilesSidebar refetched 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), createObjectURL never 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 typecheck and 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