[BUG] VS Code extension host OOM: session metadata retains entire transcripts via V8 sliced strings (119 MB on disk -> 3.2 GB heap)

Status Closed — not planned
Reported on v2.1.220
Maintainer reply None cached
Activity 2 comments · opened Jul 27, 2026 · closed Jul 28, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

The VS Code extension host OOMs at Node's default V8 old-space limit (~4288 MB) about 25 seconds after every launch, before any conversation is opened. VS Code restarts it three times, then gives up and the window is left with a dead extension host.

The failure class is reported often (#8722, #10520, #23071, #22716, #12611, #19223), and #8722 correctly identified that session transcripts get loaded at startup. This report adds the part that I think explains why the threshold is so much lower than people expect: **the transcripts are not merely loaded, they are retained by the session-metadata records via V8 sliced strings**, at roughly a 27x multiple of their on-disk size.

Measured here: 119 MB of .jsonl on disk → 3.2 GB of retained heap. That is why this reproduces on a machine with only ~119 MB of history, where #8722 needed 738 MB.

The mechanism, precisely

Verified against anthropic.claude-code-2.1.220-linux-x64/extension.js and three heap snapshots taken at the moment of the OOM.

1. Discovery fans out across git worktrees. The session store enumerates worktrees and, for each, the matching ~/.claude/projects/<sanitized-cwd> directory (minified oIt):

async function oIt(e){
  let t=[];
  for (let r of await tfe(e)) {            // worktrees of the workspace
    if (r===e) continue;
    for (let n of await kb(r))             // project dirs for that worktree
      t.push({worktreePath:r, projectDir:n});
  }
  return t;
}

A repo checked out as N worktrees produces N distinct ~/.claude/projects keys, so a multi-root workspace pulls in every worktree's history, not just the current folder's. In my case that is 13 project dirs / 62 sessions / 88 MB, where the active folder alone has 6 sessions.

2. Each transcript is read and reduced to a small metadata record:

{sessionId, summary, lastModified, fileSize, customTitle, firstPrompt,
 gitBranch: Ul(r.gitBranch), cwd: Ul(r.cwd), tag: Ul(r.tag), createdAt}

3. Those small fields are substrings of the transcript, and V8 substrings are SlicedStrings that hold a pointer to their parent. A 20-byte gitBranch therefore keeps the entire multi-megabyte transcript string alive. Every metadata record pins its whole source file, for as long as the record lives.

This is what the heap snapshot shows. Retainer chain, identical for all sampled strings:

transcript content (multi-MB)
  ^-- [sliced string]                      via parent
    ^-- [object] Object                    via gitBranch      <- session metadata record
      ^-- [object] Array                   via element:403
        ^-- [array]                        via 2
          ^-- [object] Generator           via parameters_and_registers
            ^-- [object] system / Context  via extension

Snapshot totals at the moment of death:

| Metric | Value |
|---|---|
| Total heap | 4016.7 MB |
| string share | 94.8% (3807.6 MB) |
| Transcript-like strings | 27,842 nodes / 3196 MB |
| Transcripts on disk | 119.4 MB across 94 .jsonl |
| Amplification | ~27x |

All three snapshots agree (94.8% / 94.6% strings), and the crash timing is identical across 10 sessions in one day: 3 heap OOMs per session, ~58 s apart.

The Generator / system / Context frames show this happening inside the in-flight async discovery pass, so peak retention is the sum over all discovered files rather than one at a time.

What Should Happen?

Enumerating past sessions to populate a picker should cost memory proportional to the metadata (a few hundred bytes per session), not to the full transcript corpus, and should not scale with the number of git worktrees.

Suggested fix

Ordered by effort. (1) alone removes the amplification.

1. Detach the extracted fields from their parent buffer. Where the metadata record is built, force a flat copy so the small field stops pinning the transcript:

const detach = s => (typeof s === 'string' ? Buffer.from(s, 'utf8').toString('utf8') : s);
// or the cheaper V8 idiom: s => (' ' + s).slice(1)

return {
  sessionId, lastModified, fileSize, createdAt,
  summary:     detach(summary),
  customTitle: detach(customTitle),
  firstPrompt: detach(firstPrompt),
  gitBranch:   detach(Ul(r.gitBranch)),
  cwd:         detach(Ul(r.cwd)),
  tag:         detach(Ul(r.tag)),
};

The retained set becomes the metadata itself — kilobytes instead of gigabytes. This is a contained change at one call site and needs no architectural rework.

2. Stop reading whole transcripts for metadata. These fields come from the first record, and lastModified/fileSize already come from stat. Read a bounded prefix (say 64 KB) via a stream and stop at the first parsed line, with a bounded tail read only if a field is missing. Today a 6 MB transcript is read in full to extract ~200 bytes. This also fixes the I/O cost, which is what makes startup slow even when it does not crash.

3. Cache metadata keyed by (path, mtime, size) so repeat activations do not re-read unchanged transcripts. Session files are append-only, so this hits almost always.

4. Bound the fan-out. Do the cross-worktree enumeration lazily when the picker is opened rather than on activation, and cap read concurrency so peak memory is bounded by the cap rather than by the corpus size.

Even with (1), (4) is worth doing: unbounded concurrency means many file contents are live simultaneously during the scan.

Error Messages/Logs

<--- Last few GCs --->
[18858:0x17f99000] 27712 ms: Mark-Compact 4001.6 (4118.1) -> 3991.3 (4156.1) MB, pooled: 0 MB, 151.09 / 0.02 ms  (average mu = 0.668, current mu = 0.200) allocation failure; scavenge might not succeed
[18858:0x17f99000] 28004 ms: Mark-Compact 4023.6 (4156.4) -> 4002.2 (4165.6) MB, pooled: 10 MB, 164.36 / 2.97 ms  (average mu = 0.569, current mu = 0.436) allocation failure; scavenge might not succeed

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
----- Native stack trace -----
 1: 0x74eae8 node::OOMErrorHandler(char const*, v8::OOMDetails const&)
...
10: 0xc5dc25 v8::String::NewFromTwoByte(v8::Isolate*, unsigned short const*, v8::NewStringType, int)

[ExtensionHostConnection] <18858> Extension Host Process exited with code: null, signal: SIGABRT.

Steps to Reproduce

  1. Have a repo checked out as several git worktrees (I have 34), and accumulate Claude Code history across them — ~120 MB of .jsonl under ~/.claude/projects is enough. du -sh ~/.claude/projects to check.
  2. Open a multi-root workspace (.code-workspace) whose folders include several of those worktrees.
  3. Connect VS Code with the Claude Code extension enabled.
  4. Watch the extension host: ps -o rss= -p $(pgrep -f 'type=extensionHost'). It climbs to ~4 GB within ~25 s and aborts. Three restarts, then VS Code stops retrying.

The trigger is startup discovery, so it does not require opening a conversation.

Note on the --max-old-space-size workaround

Several existing issues suggest raising the heap via NODE_OPTIONS, and reporters consistently say it does nothing (#10520 explicitly). That is expected: server-main.js runs delete env.NODE_OPTIONS on the extension-host environment before forking it, so the variable never reaches the process. It is not a viable mitigation and should probably stop being recommended.

For anyone needing to capture an exthost heap snapshot, the flags must go through execArgv instead — adding them to the "$ROOT/node" ... server-main.js line of .vscode-server/cli/servers/Stable-<commit>/server/bin/code-server works, because VS Code forwards the server's process.execArgv to the forked extension host:

--heapsnapshot-near-heap-limit=1 --diagnostic-dir=<dir>

Environment

  • Claude Code version: 2.1.219 (CLI), extension 2.1.220-linux-x64
  • Regression: I don't know — this machine's transcript corpus grew past the threshold gradually
  • Platform: Anthropic API
  • OS: Ubuntu 26.04 LTS in an LXD container (96 GiB cgroup limit, 6.4 GB in use — not memory-starved)
  • VS Code: Remote-SSH, server commit 1b6a188127eeaf9194f945eb6eb89a657e93c54c, Node v24.18.0 (default heap limit 4288 MB, no --max-old-space-size set by VS Code)
  • Workspace: 24-folder multi-root over 34 git worktrees

Additional Information

The current workaround is the one from #8722 — prune or archive ~/.claude/projects — but because retention is ~27x the on-disk size, the practical ceiling is far lower than the disk footprint suggests. On this machine ~120 MB of history is already fatal.

Happy to share the heap snapshots or run further analysis if useful.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗