TUI: idle session sustains ~2.4 cores (main V8 thread ~95%) — unthrottled ~60fps Ink render heartbeat (Windows)

Open 💬 1 comment Opened Jun 18, 2026 by FelixIsaac

Claude Code TUI: ~2.4 cores sustained CPU at idle (main V8 thread near-saturated) on Windows

Environment

| Item | Value |
|---|---|
| Claude Code version | 2.1.175 (Bun-compiled single-file native install) |
| Binary | ~/.local/share/claude/versions/2.1.175 (≈245 MB) |
| OS | Windows 11 Pro 26200 |
| Terminal | WezTerm (GPU-accelerated), Git Bash login shell as parent |
| Hardware | 16 logical cores; 64 GB RAM physical (~60 GB OS-visible — iGPU/reserved); 26 GB free at test — not RAM-constrained |
| Launch flags | --ide -d (+ model/session flags) |
| Session age | ~2 days |
| MCP servers attached | a browser-automation MCP (~60+ tools) + 2 lighter stdio servers |
| Concurrency | ~9 claude.exe + ~23 node processes; many concurrent sessions |

Summary

A single idle Claude Code session (no prompt running, user not typing) sustains ~2.4–2.5 logical cores, with ~95% of one core on the main V8/JS event-loop thread. Load is bursty at a sub-10s cadence, IO-flat, and the session's own transcript is tiny (1.96 MB) — so this is compute on the event loop, not disk, not transcript re-read, not thread explosion.

This report separates (a) cost that is expected (heavy concurrency + large MCP surface) from (b) a genuine main-thread inefficiency authors can fix — the headline being the idle main-thread CPU burn, with the strongest structural candidate being an unthrottled ~60 fps Ink "keepAlive" render heartbeat.

Reproduction

  1. Windows 11, native (Bun) install, WezTerm.
  2. Launch with --ide -d and several MCP servers (notably one with a large tool surface, e.g. a browser MCP exposing ~60 tools).
  3. Use it for a long, multi-day session (growing context).
  4. Stop interacting — fully idle, no prompt, no keystrokes.
  5. Measure per-thread CPU deltas over a tight ~15s window.

Observed: ~2.4–2.5 cores still consumed at idle; main thread near-saturated.

Method note (important): coarse TotalProcessorTime sampling at 10s intervals misreports this as ~0.1–0.2%/16 cores (looks idle) because the load is spiky at a sub-10s cadence. Use per-thread millisecond deltas over a tight window, not coarse interval sampling.

Measurements / Evidence

Target: claude.exe PID 14524 (highest-CPU of 9 live claude processes; 6273 cumulative CPU-sec).

Idle CPU (user idle both runs, no prompt):

| Run | CPU in 15s window | % of one core |
|---|---|---|
| 1 | 37,672 ms | 251.1% (~2.5 cores) |
| 2 | 35,922 ms | 239.5% (~2.4 cores) |

Cumulative CPU rose ~+405 CPU-sec in a few minutes of wall-clock while the user did nothing — sustained, not one-shot.

Thread breakdown (15s window):

| Thread | Role | CPU in window | Lifetime |
|---|---|---|---|
| TID 29484 | V8 main / JS event loop | 14,328 ms (~95% of one core) | 2457s (~40% of all process CPU) |
| 7× libuv workers | threadpool (timers/fs/transport) | ~3.0–3.8s each | ~400–475s each |

  • Thread count normal: target 37 vs a light reference session 36 — same pool running hot, not extra threads.
  • IO flat at idle: 20s delta = Read 0.25 MB / 12 ops, Write 0.28 MB / 115 ops.
  • This session's transcript is tiny: <session>.jsonl = 1.96 MB. (The multi-GB transcript total on disk is spread across many sessions; not this session's render input.) Rules out transcript re-read as the per-tick driver.
  • Debug (-d) is not the live driver: active debug file grew 2.6 KB in 12s (~217 B/s). (Separate housekeeping concern: ~/.claude/debug/ holds 702 files / 354 MB, unbounded.)

Signature: main-thread-bound + bursty + idle + IO-flat + normal thread count → a background render/poll loop, not user-driven inference.

flowchart TD
    A[Idle session ~2.4 cores] --> B[TID 29484 V8 main ~95% of 1 core]
    A --> C[7x libuv workers ~3s/15s each, even]
    B --> D{Bundle candidates}
    D --> E["~60fps Ink keepAlive render<br/>setInterval A,16 / BR=16"]
    D --> F["1s session-status tick X8A=1000"]
    D --> G["1s ScheduledTasks watcher Dcf=1000"]
    C --> H["Recurring async wakeups:<br/>chokidar awaitWriteFinish 200ms,<br/>MCP transport, timers"]
    E -.cost scales with.-> I["Rendered tree size:<br/>long context + large browser MCP surface"]

Suspected root cause(s) — with bundle citations

The JS is embedded as readable text inside the Bun binary (greppable, not decompilation). Byte offsets are install-specific, so code is quoted rather than located.

1. (Primary, author-fixable) ~60 fps Ink "keepAlive" render heartbeat — Qn1 / BR=16

function Qn1(H){let q=new Map,K=null,$=H,_=performance.now(),f=0;
  function A(){f=performance.now()-_;for(let O of q.keys())O()}   // tick: run every subscriber
  function z(){if([...q.values()].some(Boolean)){...K=setInterval(A,$)} else if(K)clearInterval(K)}
  ...subscribeKeepAlive(O){return Y(O,!0)}...}
function dn1(){return Qn1(BR)}     // BR=16 -> ~60Hz

setInterval(A,16) fires ~60×/sec whenever any keepAlive subscriber is active (spinners, activity indicators, "esc to interrupt" timers). Each tick invokes every subscriber → an Ink/React re-render. With no FPS throttle, per-tick cost grows with the rendered tree (long context + large MCP tool surface). Most plausible steady-state main-thread driver; matches the "laggy while working / idle still hot" symptom.
Author angle: throttle to 30 fps / coalesce ticks / shrink the reconciled tree / fully stop the interval when nothing is animating (verify z() actually tears down when the only subscribers are non-animating).

2. (Contributing, aggregates across concurrency) 1s timers + chokidar polling

  • Per-session session-status updater: function i(){t(),o(),l=setInterval(o,X8A)} // X8A=1000 — every 1s filters/maps the activity list + updateSessionStatus(...).
  • ScheduledTasks watcher: setInterval(p,Dcf) // Dcf=1000 (1s) + chokidar add/change/unlink.
  • Skill/command dir watcher: setInterval(h,jwA) // jwA=1e4 (10s) + awaitWriteFinish:{stabilityThreshold:500,pollInterval:200} → 200 ms polling of pending-write files.
  • Keybindings watcher force-polls one file every 2s (usePolling:!0,interval:2000).

Each is light per process, but ×25–30 concurrent sessions the 1s ticks and chokidar's 200 ms awaitWriteFinish polling become meaningful aggregate wakeups — and Windows leans on polling far more than Linux inotify.
Author angle: unref()/lengthen ticks, coalesce or share watcher/status work across sessions via the agent daemon.

libuv worker pool

Even ~3s/15s across ~7 workers is consistent with recurring async wakeups (timers, fs/watch, MCP transport) feeding the main loop — supports the recurring-loop diagnosis.

What's expected vs. what's a bug

Expected (NOT filed as bug): MCP subprocesses across many sessions; a large MCP tool surface (~60 tools) inflating per-session schema/registry + context; large transcript volume on disk (retention configured high); contention from 9 claude + 23 node procs on 16 cores; WezTerm GPU compositing under load.

Bug (filed): an idle session pinning ~95% of one core on the main V8 thread via a background render/poll loop that does not quiesce when nothing is animating, with per-tick cost scaling with rendered-tree size.

Concrete suggestions for the authors

  1. Throttle the Ink render heartbeat from ~60 fps (BR=16) to ≤30 fps, or coalesce multiple keepAlive subscribers into one rAF-style scheduler.
  2. Guarantee teardown: confirm z() clears the interval when the only live subscribers are non-animating; an idle TUI should fire 0 render ticks/sec.
  3. Decouple per-tick cost from tree size: memoize / virtualize the reconciled tree so a large context or large MCP tool list doesn't make every frame more expensive.
  4. Coalesce/unref() the 1s setInterval watchers (session-status, ScheduledTasks) and lengthen chokidar awaitWriteFinish.pollInterval on Windows; consider sharing watcher work across co-located sessions via the agent daemon.
  5. Bound ~/.claude/debug/ (rotation/cap) — 702 files / 354 MB unbounded.

How to reproduce the measurement (for triage)

PowerShell, per-thread deltas over a tight window (coarse sampling will show "idle"):

$p=Get-Process -Id <PID>; $t1=$p.Threads | %{ @{Id=$_.Id;T=$_.TotalProcessorTime} }
Start-Sleep 15; $p.Refresh()
$p.Threads | %{ $b=$t1|?{$_.Id -eq $_.Id}; "{0}: {1} ms" -f $_.Id, ($_.TotalProcessorTime - $b.T).TotalMilliseconds }

---
Filed from a verified local investigation (live per-thread profiling + static analysis of the installed 2.1.175 bundle). Happy to share raw thread/IO samples.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗