[BUG] Claude Code VS Code extension — main-thread saturation, UI freezes, and memory growth during extended thinking

Open 💬 0 comments Opened Jun 29, 2026 by LucLLM

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

During extended thinking, the chat webview freezes/lags and its memory climbs. The renderer's single main thread is saturated by two per-token costs: the thinking block's full markdown is re-parsed on every streamed chunk (even while the block is collapsed), and the block's React subtree is torn down and rebuilt on every token (its React key changes each token) — churning DOM nodes and React state faster than the GC can reclaim them.

Visible symptoms:

  • The bottom spinner stutters or freezes entirely.
  • The chat lags behind the model.
  • Expanding/collapsing the thinking block is sluggish or freezes entirely.
  • Memory climbs into the GBs on long thinking traces and doesn't readily recede.

It scales with the model's token throughput (faster thinking = worse) and with conversation length.

What Should Happen?

The chat should stay responsive throughout thinking — the spinner animates smoothly, expand/collapse is snappy, and memory stays flat. Concretely: the thinking block shouldn't re-parse its markdown when collapsed, and it shouldn't unmount/remount its React subtree on every token.

Error Messages/Logs

Steps to Reproduce

  1. Open the VS Code extension and start a session with extended thinking enabled and a non-trivial thinking budget.
  2. Ask something that produces a long thinking block.
  3. During thinking, observe: the bottom spinner stutters/freezes; the chat lags behind the model; expanding or collapsing the thinking block is sluggish or freezes entirely; memory climbs and stays elevated.
  4. Note: files on disk keep updating throughout — the agent host stays responsive; only the renderer is starved.

Claude Model

Not sure / Multiple models

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.195

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

VS Code integrated terminal

Additional Information

I patched the minified bundle to confirm the root cause and the fixes below. Identifiers are from the minified production bundle (webview/index.js, v2.1.195) so names are mangled; only the structure matters.

Root cause 1 — the thinking text is re-parsed on every streamed chunk, even when collapsed (biggest contributor)

The thinking text renders through react-markdown (hpoY), which rebuilds the unified processor and parses the entire string on each render — and the block re-renders on every thinking_delta event, at a rate that scales with the model's token throughput (tens of times per second, more on fast streams). The child is mounted unconditionally — even when the <details> is collapsed:

// zxe — thinking block component
return <details open={c} …>
  <summary>…{tokenPill}…</summary>
  <div className={thinkingContent}>{renderMarkdown(e.thinking)}</div>   // parsed even when closed
</details>;

Parse cost is O(text length) per update, so over a streaming block it's O(n²) cumulative — paid for content the user isn't even looking at.

Root cause 2 — the thinking block remounts on every token (memory growth + residual lag when collapsed)

Each content-block wrapper derives its React key from a per-token timestamp, and that key is used as the element key:

class ContentBlock {                       // lp
  hash = Math.random();
  lastModifiedTime = Date.now();
  get key() { return this.hash + this.lastModifiedTime }   // changes every token
  updated() { …; this.lastModifiedTime = Date.now() }      // called per content_block_delta
}
// per-message component (z8t):
i.content.map((h) => b(IC, { content: h, … }, h.key))      // ← key changes every token

When a React key changes between renders, React unmounts the old subtree and mounts a new one (it doesn't just re-render). So every token: tear down the block's DOM, run every effect cleanup, build a fresh subtree, run every effect, create fresh DOM. That churn — and the garbage it generates — is the main source of the memory growth, and it's also why thinking can stay laggy even if the markdown parse is skipped (for example when the block is collapsed): the remounts alone are enough to saturate the main thread.

This is compounded by content blocks being mutated in place during streaming (e.thinking += …, lastModifiedTime = Date.now()). React works best with immutable updates; mutating in place defeats React.memo (the comparator reads content.key on the same mutated object, so it always compares equal), which is why the remount was effectively the only thing delivering streamed content to the DOM — and why a natural first attempt, wrapping the markdown renderer in memo, did nothing.

Fix A — skip the parser while collapsed

Conditionally mount the markdown child only when expanded:

// zxe
c ? b("div", {className: pp.thinkingContent, children: b(hp, {content: e.thinking, context: t})}) : null

Collapsed (the default) becomes zero parse cost. Biggest single win.

Fix B — stop remounting the streaming block

Give the block a stable React key (the map index, not hash+lastModifiedTime) and fix the memo comparator to detect content changes via a render-time snapshot instead of re-reading the mutated object:

// z8t — stable element key, plus pass a snapshot prop
i.content.map((h, idx) => b(IC, { content: h, contentKey: h.key, … }, idx))
// IC — comparator compares the snapshot, not the mutated object
(e, t) => e.contentKey === t.contentKey && …

Now the block re-renders in place when its content changes (no unmount/remount, no churned garbage) and stays memoized when it doesn't. This also fixes a latent bug: the old remounts reset component state every token (e.g. the expand/collapse toggle).

Longer-term (the principled versions, for the source)

  • Granular per-block reactivity + immutable content updates — stop mutating content in place; emit a new content object per delta and signal just the changed block. Makes React keys and memo "just work," and means a token re-renders one block instead of the whole list.
  • Off-main-thread (Web Worker) parsing, or incremental/tail-only parsing — for the expanded case, where the text genuinely changes each update and is still re-parsed. A worker removes the parse from the main thread entirely; incremental parsing re-parses only the live tail (O(n) vs O(n²)) but must be fence-aware (markdown isn't safely left-to-right-finalizable — unclosed ``` ` `` fences, reference-link definitions used before their definition). remark/micromark` (used here) aren't incremental out of the box.

Minor, not addressed here

The per-event handler also copies and re-assigns the entire messages array on every token (processIncomingMessage[...messages.value] → regroup → messages.value = x2(OG(t,…))), re-rendering the whole list per token. In practice this was a minor contributor (memoized blocks short-circuit, so the cost is mostly the array rebuild), but it scales with conversation length and is fixed by the granular-reactivity change above.

View original on GitHub ↗