Context
A production user (20 GB Mac) reported excessive RAM usage. The app is designed to stay open all day with every feature in use (inbox, journal, calendar, voice recorder, Agent Chat, graph, canvas, sync). A full static audit of the desktop app was performed on 2026-08-07 (main @ e4c9c90 ) across 8 subsystems: main-process lifecycle, sync/CRDT, database/indexer/projections, renderer hooks/services, heavy components (graph/editor/canvas/PDF), Agent Chat/MCP, media/voice/notifications, and the IPC boundary.
Every finding below is filed as its own issue, all carrying the audit-2026-08 label (plus memory-footprint). Severity is in each title: [MAJOR] = unbounded growth under normal daily use or a permanent CPU burn · [HIGH] = grows/leaks on a common action · [MEDIUM] = bounded but wasteful · [LOW] = minor/latent · [CLEANUP] = dead code carrying latent cost.
Rules for implementers (AI agents included)
Every child issue starts with the same instruction: first confirm the issue is still valid on current main (line numbers may have drifted; a later commit may have fixed it). Only implement after confirming. If invalid, comment evidence and close. Follow CLAUDE.md conventions, keep changes surgical, add tests, and use the memory snapshot harness (#446 ) for before/after evidence where applicable.
Most likely explanation of the 20 GB report
Y.Docs pinned forever by stale windowIds (compaction disabled as a side effect) · editors never destroyed + journal remounting an editor per external sync change · agent transcripts never evicted · orphaned Whisper utility processes (hundreds of MB each) · leaked PDF-export renderer processes · full note bodies retained in debugState · offline CRDT buffers.
Related pre-existing issues (folded into this epic)
Issue checklist (121 issues)
🔴 MAJOR — unbounded growth in daily use / permanent CPU burn (32)
[MAJOR][telemetry] Every tracked event synchronously rewrites the entire queue file #988 — [telemetry] Every tracked event synchronously rewrites the entire queue file
[MAJOR][calendar] Google Calendar sync runner await-race creates an unstoppable duplicate interval #989 — [calendar] Google Calendar sync runner await-race creates an unstoppable duplicate interval
[MAJOR][sync] debugState retains the full markdown of every written-back note forever #990 — [sync] debugState retains the full markdown of every written-back note forever
[MAJOR][projections] inbox-stats projector rebuilds the whole stats table on every single inbox event #991 — [projections] inbox-stats projector rebuilds the whole stats table on every single inbox event
[MAJOR][projections] Every event is enqueued into all 5 lanes; slow embedding lane pins every queued note body; queues unbounded #992 — [projections] Every event is enqueued into all 5 lanes; slow embedding lane pins every queued note body; queues unbounded
[MAJOR][projections] Full-vault FTS teardown + disk re-scan on every vault open; not abortable, keeps reading the old vault after a switch #993 — [projections] Full-vault FTS teardown + disk re-scan on every vault open; not abortable, keeps reading the old vault after a switch
[MAJOR][projections] reconcileMissingFiles loads 100k rows and runs synchronous fs.existsSync per note on the main thread #994 — [projections] reconcileMissingFiles loads 100k rows and runs synchronous fs.existsSync per note on the main thread
[MAJOR][sync] CRDT update queue grows unbounded while paused/offline and silently discards buffered updates at shutdown #995 — [sync] CRDT update queue grows unbounded while paused/offline and silently discards buffered updates at shutdown
[MAJOR][sync] Full Y.Doc snapshot re-encoded, re-encrypted, and re-uploaded every second while typing #996 — [sync] Full Y.Doc snapshot re-encoded, re-encrypted, and re-uploaded every second while typing
[MAJOR][crypto] Every vault-key fetch pays a disk read + safeStorage decrypt + OS keychain round-trip; no migrated latch #997 — [crypto] Every vault-key fetch pays a disk read + safeStorage decrypt + OS keychain round-trip; no migrated latch
[MAJOR][sync] Every full sync schedules a CRDT pull for EVERY note in the vault; full sync runs on every reconnect #998 — [sync] Every full sync schedules a CRDT pull for EVERY note in the vault; full sync runs on every reconnect
[MAJOR][crdt] Stale windowIds permanently pin Y.Docs: no BrowserWindow-closed hook, eviction and compaction disabled #999 — [crdt] Stale windowIds permanently pin Y.Docs: no BrowserWindow-closed hook, eviction and compaction disabled
[MAJOR][ipc] 39 unguarded getAllWindows() send loops — destroyed-window throw can roll back sync (regression of #935) #1000 — [ipc] 39 unguarded getAllWindows() send loops — destroyed-window throw can roll back sync (regression of fix(main): route window fan-out sends through guarded broadcastToAllWindows #935 )
[MAJOR][ipc] CRDT hot path converts Uint8Array→number[] per keystroke, both directions, per receiving window #1001 — [ipc] CRDT hot path converts Uint8Array→number[] per keystroke, both directions, per receiving window
[MAJOR][agent] Cancelled tool approval never settles its promise — leaks an MCP server + open socket per cancellation, blocks quit #1002 — [agent] Cancelled tool approval never settles its promise — leaks an MCP server + open socket per cancellation, blocks quit
[MAJOR][agent] Agent MCP HTTP server stops without destroying connections — quit hangs, then forced app.exit(1) #1003 — [agent] Agent MCP HTTP server stops without destroying connections — quit hangs, then forced app.exit(1)
[MAJOR][agent-chat] messagesByConversation is never evicted — every conversation's full transcript retained for the app lifetime #1004 — [agent-chat] messagesByConversation is never evicted — every conversation's full transcript retained for the app lifetime
[MAJOR][agent-chat] Every streamed token rebuilds the message array and re-renders every agent consumer #1005 — [agent-chat] Every streamed token rebuilds the message array and re-renders every agent consumer
[MAJOR][agent-chat] Each tool result rewrites every message of every loaded conversation #1006 — [agent-chat] Each tool result rewrites every message of every loaded conversation
[MAJOR][agent-chat] SidebarTabs rescans every message of every conversation on every streamed token #1007 — [agent-chat] SidebarTabs rescans every message of every conversation on every streamed token
[MAJOR][agent] Blocking spawnSync binary detection on every backend call freezes the main process #1008 — [agent] Blocking spawnSync binary detection on every backend call freezes the main process
[MAJOR][agent] Local backend re-probes the provider — including two full model inferences — on every turn #1009 — [agent] Local backend re-probes the provider — including two full model inferences — on every turn
[MAJOR][agent-mcp] 65 MCP tools re-registered with zod schemas on every HTTP request (stateless transport) #1010 — [agent-mcp] 65 MCP tools re-registered with zod schemas on every HTTP request (stateless transport)
[MAJOR][voice] Cancelled recording is still delivered — full decode, WAV encode, vault write, and transcription run anyway #1011 — [voice] Cancelled recording is still delivered — full decode, WAV encode, vault write, and transcription run anyway
[MAJOR][voice] MediaRecorder constructor/start throw leaves the mic hot and the stream unreachable #1012 — [voice] MediaRecorder constructor/start throw leaves the mic hot and the stream unreachable
[MAJOR][hooks] use-all-tags re-subscribes its IPC listener on every render (per keystroke in tag autocomplete) #1013 — [hooks] use-all-tags re-subscribes its IPC listener on every render (per keystroke in tag autocomplete)
[MAJOR][capture-bar] Global keydown listener torn down and re-added on every keystroke; duplicate 'q' binding in split view #1014 — [capture-bar] Global keydown listener torn down and re-added on every keystroke; duplicate 'q' binding in split view
[MAJOR][graph] Graph is torn down, Sigma+WebGL recreated, and physics fully re-simulated on every note/task save #1015 — [graph] Graph is torn down, Sigma+WebGL recreated, and physics fully re-simulated on every note/task save
[MAJOR][graph] Physics never settles if a node drag ends outside the window — permanent 60 fps loop #1016 — [graph] Physics never settles if a node drag ends outside the window — permanent 60 fps loop
[MAJOR][editor] BlockNote/ProseMirror editors are never explicitly destroyed; journal remounts a new editor per external sync change #1017 — [editor] BlockNote/ProseMirror editors are never explicitly destroyed; journal remounts a new editor per external sync change
[MAJOR][viewers] PDF viewer loads the document twice and renders every page as an unvirtualized thumbnail #1018 — [viewers] PDF viewer loads the document twice and renders every page as an unvirtualized thumbnail
[MAJOR][tabs] useTabActions does not prevent re-renders — every tab state change re-renders ~77 consumers including the whole AppContent #1019 — [tabs] useTabActions does not prevent re-renders — every tab state change re-renders ~77 consumers including the whole AppContent
Also MAJOR-equivalent, pre-existing: #449 (sync progress/conflict pruning — duplicates an audit finding)
🟠 HIGH — leaks/burns on a common action (37)
Also HIGH-equivalent, pre-existing: #448 (URL preview cache — duplicates an audit finding)
🟡 MEDIUM — bounded but wasteful (32)
[MEDIUM][main] Three independent 60-second DB pollers run for the whole app lifetime; two do work when idle #1057 — [main] Three independent 60-second DB pollers run for the whole app lifetime; two do work when idle
[MEDIUM][main] Window resize sync-writes the whole pretty-printed config every 400 ms; maximize bypasses the debounce #1058 — [main] Window resize sync-writes the whole pretty-printed config every 400 ms; maximize bypasses the debounce
[MEDIUM][sync] 4 COUNT(*) scans per queue enqueue and a SQLite read per WS message for isPaused #1059 — [sync] 4 COUNT(*) scans per queue enqueue and a SQLite read per WS message for isPaused
[MEDIUM][sync] manifest-check materializes and JSON.stringifies every synced row in the vault, mostly wasted #1060 — [sync] manifest-check materializes and JSON.stringifies every synced row in the vault, mostly wasted
[MEDIUM][sync] Module-level per-note maps grow for the session and survive vault switches #1061 — [sync] Module-level per-note maps grow for the session and survive vault switches
[MEDIUM][sync] Attachment transfer progress broadcast per chunk, unthrottled, to every window; concurrent transfers clobber the shared callback #1062 — [sync] Attachment transfer progress broadcast per chunk, unthrottled, to every window; concurrent transfers clobber the shared callback
[MEDIUM][settings] Every settings write echoes settings:changed back to the originating window (7 sites; sync apply sends 3 per window) #1063 — [settings] Every settings write echoes settings:changed back to the originating window (7 sites; sync apply sends 3 per window)
[MEDIUM][agent] Full history is decrypted, zod-parsed, and re-assembled multiple times per message #1064 — [agent] Full history is decrypted, zod-parsed, and re-assembled multiple times per message
[MEDIUM][agent] Streaming deltas broadcast to all windows; only the conversation's window needs them #1065 — [agent] Streaming deltas broadcast to all windows; only the conversation's window needs them
[MEDIUM][ai-inline] Chat server: no abort wiring, never stopped at quit, unbounded request body, error path orphans the listening socket #1066 — [ai-inline] Chat server: no abort wiring, never stopped at quit, unbounded request body, error path orphans the listening socket
[MEDIUM][voice] AudioContext leaked when recorder waveform setup throws #1067 — [voice] AudioContext leaked when recorder waveform setup throws
[MEDIUM][inbox] AudioContext leaked on every failed playback-waveform decode #1068 — [inbox] AudioContext leaked on every failed playback-waveform decode
[MEDIUM][voice] Waveform loop does 60 fps DSP but uses 20 fps of it; fftSize 8× larger than needed #1069 — [voice] Waveform loop does 60 fps DSP but uses 20 fps of it; fftSize 8× larger than needed
[MEDIUM][voice] Voice-memo preparation transiently allocates ~100 MB on the renderer main thread #1070 — [voice] Voice-memo preparation transiently allocates ~100 MB on the renderer main thread
[MEDIUM][snooze] One 60s interval + one document listener per snoozed row #1071 — [snooze] One 60s interval + one document listener per snoozed row
[MEDIUM][calendar] No midnight rollover — "Today" and calendar ranges go stale in an all-day session #1072 — [calendar] No midnight rollover — "Today" and calendar ranges go stale in an all-day session
[MEDIUM][canvas] Full scene serialize + JSON round-trip every 800 ms of interaction; three copies of the scene in memory #1073 — [canvas] Full scene serialize + JSON round-trip every 800 ms of interaction; three copies of the scene in memory
[MEDIUM][hooks] Global shortcut hooks re-register window keydown listeners on every tab/inbox state change #1074 — [hooks] Global shortcut hooks re-register window keydown listeners on every tab/inbox state change
[MEDIUM][sync] setMaxListeners(50) on three sync emitters masks future listener-leak regressions #1075 — [sync] setMaxListeners(50) on three sync emitters masks future listener-leak regressions
[MEDIUM][agent-chat] Mention picker fires two undebounced IPC searches per keystroke #1076 — [agent-chat] Mention picker fires two undebounced IPC searches per keystroke
[MEDIUM][viewers] Image viewer re-renders per mousemove while panning #1077 — [viewers] Image viewer re-renders per mousemove while panning
[MEDIUM][projections] Queue mechanics: O(n) Array.shift dequeue, per-file global drain barrier, spinnable drain loop blocking vault close #1078 — [projections] Queue mechanics: O(n) Array.shift dequeue, per-file global drain barrier, spinnable drain loop blocking vault close
[MEDIUM][vault] getConfig() is a synchronous disk read with a process-wide side effect, called on hot paths #1079 — [vault] getConfig() is a synchronous disk read with a process-wide side effect, called on hot paths
[MEDIUM][graph] getLocalGraph builds the entire vault graph, then filters to depth #1080 — [graph] getLocalGraph builds the entire vault graph, then filters to depth
[MEDIUM][image] ImageProcessingBridge pendingRequests has no cap — bursts queue Buffers and timers in main #1081 — [image] ImageProcessingBridge pendingRequests has no cap — bursts queue Buffers and timers in main
[MEDIUM][agent-chat] Reducer allocation churn: full re-sort per message event, O(N) reduce per tool call, unbounded icon promise cache #1082 — [agent-chat] Reducer allocation churn: full re-sort per message event, O(N) reduce per tool call, unbounded icon promise cache
[MEDIUM][projections] Embedding-projector state hygiene: pendingEmbedding only drained on vault open; module-global vault path provider never reset #1083 — [projections] Embedding-projector state hygiene: pendingEmbedding only drained on vault open; module-global vault path provider never reset
[MEDIUM][crdt] SYNC_STEP_1 opens a doc with no windowId — actively-used doc is eligible for mid-edit eviction #1084 — [crdt] SYNC_STEP_1 opens a doc with no windowId — actively-used doc is eligible for mid-edit eviction
[MEDIUM][crdt] Renderer CRDT events use one global channel — every provider filters every note's updates #1085 — [crdt] Renderer CRDT events use one global channel — every provider filters every note's updates
[MEDIUM][telemetry] log-ship ring buffer reallocates and re-parses up to 200 timestamps on every warn/error line #1086 — [telemetry] log-ship ring buffer reallocates and re-parses up to 200 timestamps on every warn/error line
[MEDIUM][shortcuts] Saving keyboard settings silently kills the quick-capture fallback global shortcut #1087 — [shortcuts] Saving keyboard settings silently kills the quick-capture fallback global shortcut
[MEDIUM][editor] File drag-over re-measures blocks and re-renders the editor per dragover event #1088 — [editor] File drag-over re-measures blocks and re-renders the editor per dragover event
⚪ LOW — minor / latent (17)
💀 CLEANUP — dead code carrying latent cost (3)
Follow-up findings (15) — surfaced while fixing the audit, filed separately
These are not original audit findings. Each was discovered by an implementer while fixing one of the issues above, and deliberately left unfixed so that PR would stay surgical. All 15 were verified against origin/main @ 2ba7f5950 on 2026-08-12 before filing, and all carry the audit-2026-08-followup label.
Several outrank the issue they were found next to — the first two are correctness, not memory.
🔴 MAJOR
🟠 HIGH
🟡 MEDIUM
⚪ LOW
Suggested fix order (highest impact/effort first)
Stale windowIds pin Y.Docs (see MAJOR list) → 2. Graph rebuild + stuck physics loop → 3. Unguarded getAllWindows fan-outs → 4. Editor destroy verification (Evict idle BlockNote editors from renderer registry #447 related) → 5. debugState deletion → 6. Per-second snapshot push → 7. spawnSync per turn → 8. Agent transcript eviction + per-token rendering → 9. Telemetry sync writes → 10. Voice cancel/mic-hot fixes.
Context
A production user (20 GB Mac) reported excessive RAM usage. The app is designed to stay open all day with every feature in use (inbox, journal, calendar, voice recorder, Agent Chat, graph, canvas, sync). A full static audit of the desktop app was performed on 2026-08-07 (
main@ e4c9c90) across 8 subsystems: main-process lifecycle, sync/CRDT, database/indexer/projections, renderer hooks/services, heavy components (graph/editor/canvas/PDF), Agent Chat/MCP, media/voice/notifications, and the IPC boundary.Every finding below is filed as its own issue, all carrying the
audit-2026-08label (plusmemory-footprint). Severity is in each title:[MAJOR]= unbounded growth under normal daily use or a permanent CPU burn ·[HIGH]= grows/leaks on a common action ·[MEDIUM]= bounded but wasteful ·[LOW]= minor/latent ·[CLEANUP]= dead code carrying latent cost.Rules for implementers (AI agents included)
Every child issue starts with the same instruction: first confirm the issue is still valid on current
main(line numbers may have drifted; a later commit may have fixed it). Only implement after confirming. If invalid, comment evidence and close. FollowCLAUDE.mdconventions, keep changes surgical, add tests, and use the memory snapshot harness (#446) for before/after evidence where applicable.Most likely explanation of the 20 GB report
Y.Docs pinned forever by stale
windowIds(compaction disabled as a side effect) · editors never destroyed + journal remounting an editor per external sync change · agent transcripts never evicted · orphaned Whisper utility processes (hundreds of MB each) · leaked PDF-export renderer processes · full note bodies retained indebugState· offline CRDT buffers.Related pre-existing issues (folded into this epic)
Issue checklist (121 issues)
🔴 MAJOR — unbounded growth in daily use / permanent CPU burn (32)
Also MAJOR-equivalent, pre-existing: #449 (sync progress/conflict pruning — duplicates an audit finding)
🟠 HIGH — leaks/burns on a common action (37)
Also HIGH-equivalent, pre-existing: #448 (URL preview cache — duplicates an audit finding)
🟡 MEDIUM — bounded but wasteful (32)
⚪ LOW — minor / latent (17)
💀 CLEANUP — dead code carrying latent cost (3)
Follow-up findings (15) — surfaced while fixing the audit, filed separately
These are not original audit findings. Each was discovered by an implementer while fixing one of the issues above, and deliberately left unfixed so that PR would stay surgical. All 15 were verified against
origin/main@2ba7f5950on 2026-08-12 before filing, and all carry theaudit-2026-08-followuplabel.Several outrank the issue they were found next to — the first two are correctness, not memory.
🔴 MAJOR
🟠 HIGH
🟡 MEDIUM
⚪ LOW
Suggested fix order (highest impact/effort first)