Repository navigation
Description
Split out of #124. The debounce backoff landed there mitigates this; this is the structural fix.
Measurement
~/Library/Application Support/Solenta/coder-store.json on the daily driver is 54MB, and JSON.stringify(store, null, 2) on it takes ~58ms on the main-process event loop. Composition:
messagesByThread 52.5MB <-- 97%
workLogByThread 0.3MB
threads 0.2MB
projects/usage/settings/spend/automations/templates ~0.0MB
Within messagesByThread: 227 buckets, 0 empty, 0 orphaned (no live thread), median 190KB, largest 1.32MB / 1065 messages.
This is not a leak — the caps work
Worth stating plainly, because it rules out the obvious first guess: #89's per-thread transcript caps are doing their job. No runaway bucket, no orphaned buckets from deleted threads, largest thread is a reasonable 1.3MB. Nothing here needs garbage collecting.
The problem is purely that total size is linear in thread count: 227 threads x ~190KB = 52.5MB. Capping each thread cannot bound the total, so this gets worse with every thread and no per-thread cap will ever fix it.
The actual inefficiency
The store is one file, so a flush is all-or-nothing. When one message arrives on one thread, we re-serialize the message history of all 227 threads to persist it. That is ~0.2ms of useful work wrapped in ~58ms of waste.
Proposed fix
messagesByThread is already a map keyed by thread id, so it shards almost for free: one file per thread, e.g. messages/<threadId>.json, and a flush writes only the dirty threads. Cost per flush drops from stringifying 52.5MB to stringifying ~190KB — roughly 58ms to ~0.2ms, and it stops scaling with thread count.
workLogByThread (0.3MB) is keyed the same way and could follow, but at 0.3MB it is not worth doing on its own — only shard it if the same code path makes it nearly free.
Notes for whoever picks this up:
- Needs a migration that splits the existing 54MB file and is safe to interrupt half-way.
- The
saveNow()exit-hook path is sync on purpose (process.on(exit)cannot await) and must stay sync — sharding means it writes N files instead of one, so keep the dirty-set small and only write what changed. - Once this lands, the debounce backoff added in App beachballs when multiple threads run concurrently #124 can likely revert to a flat 250ms, which also shrinks the unsaved-data window back down from 2s.
Why not other options
- Worker thread for the stringify — does not help. Structured-cloning the store to the worker costs about what stringifying it costs.
- Dropping the
null, 2indent — ~2-3x on a problem that wants ~250x, and it costs the human-readable/diffable store file.
Activity
currentbits commentedon Aug 19, 2026
Fresh numbers, same machine, ~1 month later — the linear-in-thread-count prediction held exactly:
Flush debounce is 250 ms backing off to 2 s (
store.js:273-276), so under streaming the main process burns 162 ms per flush re-serializing archived transcripts. Theponytail:comment atstore.js:1186set its own trigger — 'per-thread files if the store stays >50MB' — and we are at 2.9x.I filed #560 for this before finding this issue; closing it as the dupe. One thing worth keeping from it: the split also shrinks the whole-store
.bak(139.5 MB today) to ~700 KB, and makes transcript export (#394) and archived-transcript deletion per-file operations.currentbits commentedon Aug 19, 2026
Live profiler evidence from the running app (pid 32564,
sample 32564 5), to size the win:rename()of the store tmp file (uv__fs_post → rename). The flush pipeline is saturated renaming 143 MB payloads.coder-store.jsonmtime for 30 s with the app near-idle (3 idle claude children): 2 full rewrites = 286 MB written in 30 s, ~9.5 MB/s sustained. At the 2 s debounce cap under active streaming that is ~71 MB/s of SSD writes — hundreds of GB per working day, all to persist a few KB of new tokens per flush.lastPushByThreadcopies are the obvious bulk. The split shrinks that too.Filed the two neighbours this profiling surfaced separately: #563 (boot sweep du storm) and #564 (renderer mounts whole transcripts).
currentbits commentedon Aug 20, 2026
One day later (2026-08-20), same machine. The curve is steepening, not flattening:
JSON.stringifyJSON.parseat boot.baksiblingComposition is now 163.8 MB archived transcripts vs 4.8 MB live — 97% of every flush is threads nobody will open again. 310 ms of synchronous main-process stringify, up to every 2 s under streaming, to persist a few KB.
Worth noting against #559/#563, which both shipped this week: worktree disk went 96 GB → 29 GB, so the disk half of the dogfooding findings is fixed. This is the one still compounding, and it is now the largest single main-process stall in the app.
currentbits commentedon Aug 21, 2026
Round 31 live measurements (2026-08-21, store now 180 MB, was 143 MB on 08-19 — growing ~18 MB/day):
While a single background worker streams:
JSON.stringify(this.data)of 180 MB on the main thread (store.js:1175) plus a 180 MB tmp write + rename. That is ~1 GB of SSD writes per 30 s (~36 MB/s sustained) for appending a few KB of transcript.sampleshows nearly all busy main-thread time inside timer-driven V8 string building/memmove, i.e. the stringify. The renderer was at 0.2 % the whole time.This is now the dominant CPU, battery, memory and SSD-wear cost of the app; everything else measured (idle tickers, renderer, pushDetail diffing) is healthy.
Interim one-liner worth taking before the shard migration: raise
SAVE_DEBOUNCE_MAX_MS(store.js:277) from 2 000 to ~15–30 000. The crash-loss window is only the transcript tail, which the provider CLIs' own session logs can restore on resume; a 15 s cap cuts flush CPU/disk by ~5–10× immediately.currentbits commentedon Aug 21, 2026
Interim mitigation landed on main as 755f0a2: SAVE_DEBOUNCE_MAX_MS raised 2 s → 15 s, cutting the measured streaming flush churn (~6 rewrites / 30 s, ~1 GB disk writes, ~24 % of a core) by roughly 7×. Same commit also removed the per-sidebar-click rewrite (#636) and the boot .bak byte-copy (#637). This issue stays open for the real fix: sharding messagesByThread.
currentbits commentedon Aug 21, 2026
Fresh live measurement (2026-08-21 evening, nightly build with the #636/#637/15s-debounce interim fixes, commit 755f0a2):
Idle is fixed. 0 store rewrites in 30 s with no active runs; main/renderer/GPU all idle under sample(1). Clicking threads and boot no longer flush.
Streaming is still the whole cost. With one thread actively streaming:
JSON.stringifyof the now 192 MB store on the main thread (store.js:1193), blocking ~1-2 s eachAlso in scope for the sharding: the quit path does an unconditional full-store sync
saveNow()(electron/runner.js:5638), so every quit rewrites all 192 MB and blocks on it. With per-thread shards, quit would write only dirty shards.The 15 s debounce + backoff bought headroom, but the per-flush cost still scales with total store size, which is growing ~10%/week. Sharding remains the real fix.
perf: cheaper store boot and flush cadence (#637, #225 interim)
perf: stop rewriting the whole store on click and boot (#636, #637, #225
currentbits commentedon Aug 22, 2026
Implemented in this worktree: transcripts now live in
messages/<threadId>.jsonnext tocoder-store.json. A flush stringifies only dirty shards plus the small envelope.saveNow()stays sync and only writes what changed (plus reclaim of an aborted in-flight snapshot).workLogByThreadstays in the envelope (still ~0.5MB).Envelope on disk has
messagesByThread: {}. Grep/forensics ofcoder-store.jsonfor transcripts will miss them; they are undermessages/.currentbits commentedon Aug 24, 2026
Implemented and Grok-reviewed in thread worktree
19029a3a-c692-4a24-8c28-2491a5173d8e.messagesByThreadnow persists as lazy per-threadmessages/<encoded-id>.jsonshards;coder-store.jsonkeeps an empty transcript envelope.saveNow()remains synchronous and stale async snapshots cannot overwrite it.Verification:
The issue remains open /
plan:doinguntil these uncommitted worktree changes are committed and landed.currentbits commentedon Aug 24, 2026
Implementation completed and Grok-reviewed in thread 19029a3a-c692-4a24-8c28-2491a5173d8e. Focused tests pass 88/88 and the production build passes.