Skip to content

The store is one 143 MB JSON blob and 94% of it is archived transcripts re-stringified on every flush #560

Closed

Description

@currentbits

electron/store.js:1186 names its own ceiling:

// ponytail: backoff under sustained save() so N streaming threads
// cannot force a whole-store stringify every 250ms. Cap 2s; reset
// on a quiet flush. Per-thread files if the store stays >50MB.

It is 143 MB. The condition for the upgrade path has been true for a while.

Read-only measurement of the live store (2026-08-19)

coder-store.json                      143.2 MB
coder-store.json.bak                  139.5 MB     (282.7 MB on disk for one workspace)

payload by key
  messagesByThread   134.35 MB   445 threads     99.5% of the blob
  workLogByThread      0.51 MB
  threads              0.49 MB
  usageByThread        0.07 MB
  everything else     < 0.01 MB

messagesByThread split
  archived threads   126.3 MB    (430 of 445 threads)
  live threads         8.1 MB
  tool-role messages      96% of message bytes

cost of one flush (this machine, warm)
  JSON.stringify(whole store)   162 ms   ← synchronous, main process (store.js:1154)
  writeFileSync                  76 ms   ← saveNow only
  JSON.parse at boot            375 ms

Flush cadence is 250 ms debounced, backing off to 2 s under sustained streaming (SAVE_DEBOUNCE_MS / SAVE_DEBOUNCE_MAX_MS, store.js:273-276). So with threads running, the main process spends 162 ms stringifying 134 MB of finished, archived transcripts every 250 ms–2 s to persist a few KB of new tokens. saveNow() adds the 76 ms write synchronously on quit (store.js:1212-1242) — correctly sync, since process.on('exit') cannot await, but it is 240 ms of quit latency that is 99.5% dead weight.

Why #89 did not close this

#89 capped per thread (MAX_MESSAGES_PER_THREAD = 1000) and moved the write off the event loop. Both correct, both still working. But the cap is per thread and the cost is aggregate: 445 threads × up to 1000 messages is an unbounded product, and it grows with every thread ever created. #89's own body listed "per-thread store files so a flush only writes the dirty thread" as the alternative it did not take. The measurement above is what the road not taken costs after two months.

The blob shape is also what forces the 139 MB .bak (store.js:1064) — a whole-store copy to protect a whole-store write.

Proposed: move the two transcript maps out of the blob

Split messagesByThread and workLogByThread into per-thread files (transcripts/<threadId>.json); leave every other key in coder-store.json.

  • The blob drops to ~700 KB. Stringify goes from 162 ms to under a millisecond, boot parse from 375 ms to ~2 ms, .bak from 139 MB to ~700 KB.
  • A flush writes the blob plus the dirty thread's file. Cost becomes proportional to the active thread, not to how long the workspace has existed. N streaming threads write N small files instead of N × 134 MB.
  • Archived transcripts stop being paid for on every keystroke, and become lazily loadable — which is what makes deleting them or exporting them (Export a thread as a self-contained, redacted bug report #394) a per-file operation instead of a store surgery.
  • The existing caps stay; they now bound one file rather than a shared budget.

Migration: on first load, if messagesByThread is present in the blob, write each thread's file and drop the key. One-way, ~445 small writes, once.

Alternative considered and rejected: keeping the blob and dropping archived transcripts on a timer. It loses history the user may still want, and it does not fix the per-flush cost for the transcripts that remain.

runner.js lastPushByThread retaining full message arrays for the process lifetime (also flagged in #89) is worth re-measuring once the store is split — that number was not part of this scan.

Tests

  • Appending to thread A writes A's transcript file and does not rewrite B's.
  • Boot with a legacy blob migrates every thread's messages to files and removes the key; a second boot is a no-op.
  • The per-thread cap and its overflow marker still apply, now per file.
  • saveNow() on exit persists the dirty thread's file, not the whole workspace.

Related

#89 (the per-thread cap this completes), #225 (transcript render perf), #124 (beachballs under concurrent threads), #394 (thread export), #556 (usage rollup, same store).

Activity

  1. currentbits commented on Aug 19, 2026

    @currentbits
    OwnerAuthor

    Duplicate of #225, which had the same diagnosis and fix a month earlier. Fresh measurements moved there.

  2. added and removed on Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions