Idle sessions burn 0.3-5% CPU because timers keep triggering garbage collection
What happened
An interactive session continues to use CPU while it sits at the input prompt. The amount varies with its configuration and session state. In controlled 30s samples, measured from /proc with no instrumentation attached:
pristine config dir (credentials only, via CLAUDE_CONFIG_DIR): 0.30% and 0.76% (bursty, see below)
typical user config (plugins, hooks, one MCP server, CLAUDE.md): 1.46% and 1.46% (steady)
Production sessions also showed sustained idle CPU use over longer periods. A roughly 1-hour production session measured 1.4-1.8%, and a 15-hour production session measured 4.4-5.0%, each across repeated 30s idle windows. These sessions weren't isolated from terminal activity, so they are supporting observations; the controlled results above are the isolated evidence.
The cost adds up across sessions. Ten idle sessions with a typical config cost ~15% of a core around the clock. The 15-hour session used 671 MB of resident memory, compared with 472 MB for the 1-hour session, and measured higher idle CPU. This suggests that session state or age may increase the cost, but these two production sessions don't isolate the cause.
Environment
Claude Code 2.1.240, native build on Bun using JavaScriptCore (JSC) and the mimalloc allocator, on Linux x86_64.
All measurements were taken from interactive sessions sitting at the input prompt with no active turn, spinner, or background tasks. Controlled sessions ran headless in a detached tmux session in an empty non-git directory and weren't touched during measurement. Two production sessions are included for comparison and labeled as such.
Thread activity
Here is where CPU time went during one 30s window in the 15-hour production session. CPU time comes from /proc/PID/task/*/stat. The wakeups/s column is calculated from voluntary context-switch deltas:
=== whole process: 131 ticks / 30s = 4.36% CPU
ticks wakeups/s comm
72 27.1 claude (main thread / event loop)
16 19.1 mi-scavenger (memory allocator cleanup thread)
6 15.0 HeapHelper (JSC garbage collector worker)
6 13.8 HeapHelper
5 14.8 HeapHelper
5 14.6 HeapHelper
5 13.9 HeapHelper
5 13.9 HeapHelper
5 13.6 HeapHelper
0 ~.4 Bun Pool 0-10, HTTP Client, fs.watch
The fresh typical-config control behaved the same way at a lower rate: the main thread had ~20 wakeups/s, and all 7 HeapHelpers had 10-12 wakeups/s each. Polling ps --ppid at 5 Hz found no child processes while idle, so all of this work happened inside the Claude process.
Syscall profile
strace -c -f counted system calls over 15s in the 15-hour production session. Minor rows are omitted. The same window had only 17 write() calls, so terminal output didn't materially inflate the profile:
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
81.34 0.564241 234 2411 314 futex
10.16 0.070445 21 3207 sched_yield
7.62 0.052823 305 173 epoll_pwait2
0.11 0.000746 7 98 15 statx
0.03 0.000221 2 74 getrusage
Across the 7 HeapHelper threads and the main thread, the idle process made ~214 sched_yield calls/s and ~161 futex operations/s, all for garbage-collection coordination rather than useful work. The futex calls wake and park the GC helper threads around each collection, while sched_yield comes from helpers spinning as they look for marking work before going back to sleep. Capping the marker pool removes most of both (see Root cause). The fresh typical-config control showed the same pattern, with 5215 sched_yield and 2802 futex calls in 10s under strace. GC logging was active in that run and lengthens collections, so the control should be used only as evidence of the pattern.
In gdb, the same behavior appears as a repeating cycle: the HeapHelper threads wait in pthread_cond_timedwait, wake on every collection, spin briefly while looking for marking work, and then wait again. That cycle accounts for the yields.
Root cause
The measurements show a three-step sequence. Timers of 200ms or less wake the idle event loop ~20-26 times/s with a typical config. While those timers fire, the process allocates ~1 MB per wakeup on average without changing the screen, causing about one Eden collection per second. Each collection wakes an 8-marker GC pool whose workers spin briefly before sleeping again, roughly doubling the CPU cost.
First, timers keep waking the event loop when there is no I/O. With a typical config, the main thread wakes from epoll_pwait2 ~20-26 times/s; with a pristine config, it wakes ~8-14 times/s. epoll_pwait2 is the call the event loop uses to sleep until something happens. About 9 in 10 calls return 0 because the requested timeout expired without an I/O event: 101 of 114 in a 10s typical-config trace and 121 of 135 in the pristine trace. Requested sleep durations range from 0-200ms. That pattern is consistent with several always-on JS timers of 200ms or less firing at different times, but the syscall trace alone doesn't identify them.
An idle JavaScript profile found several recurring callbacks. Claude honors BUN_INSPECT (BUN_INSPECT=ws://127.0.0.1:9333/x claude), and the inspector's ScriptProfiler domain reports sampled stacks with line:column positions in the embedded bundle at /$bunfs/root/cli. One short capture plus two 60-second windows on the idle typical-config session found:
callback location observed role
sendHeartbeat cli:17419:24231 keeps the transport alive; uses setInterval(keepaliveTick, keepaliveIntervalMs) and tracks pong timeouts
vnm / Nc / Q9 cli:15052 periodically calls fs.promises.lstat for a list of paths
resourceUsage host call calls process.resourceUsage() periodically
takeSlowestFrameWrite cli:2997:3351 paces terminal frame writes
(anonymous) / xgu cli:57786 dispatches timers for several sampled stacks
Across configurations, sampled windows showed statx calls at 4-15/s, mostly for missing paths, and getrusage calls at ~5x/s. In a git working directory, the main thread also checks ~/.claude.json, .git/HEAD, .git/config, .git/refs/heads/<branch>, and .git/refs/remotes/origin/HEAD once per second. The profile traced the missing-path checks to an fs.promises.lstat sweep at cli:15052 and the getrusage calls to process.resourceUsage(). The git caller wasn't sampled because the profiled session ran in a non-git directory.
The two 60-second windows captured only 112 JavaScript samples, showing that callbacks occupied very little wall time. Combined with the per-thread and syscall evidence above, this indicates that most CPU time is spent in native GC and event-loop work triggered after the callbacks run.
Second, the process allocates large amounts of memory while these idle timers fire, even though the screen doesn't change. With BUN_JSC_logGC=1, the typical-config control recorded exactly 30 Eden collections in 30s in two separate runs. An Eden collection is JSC's quick pass over recently allocated objects. Each collection reclaimed 22-26 MB from a 140-160 MB heap:
[GC: START M 143416kb => EdenCollection ... => 117560kb, p=6.528775ms ...]
This is ~22-26 MB of JS objects allocated and discarded each second, or roughly 1 MB per event-loop wakeup on average. It doesn't show that every wakeup allocates 1 MB, nor which callback performs the allocation. The screen didn't change: tmux capture-pane output taken 3s apart was identical.
A pristine-config session showed the same behavior at a lower and less steady rate. It alternated between one collection every 30s or more and stretches around 0.6/s, totaling 217 Eden and 5 full collections in its first ~12 minutes. Across the measured configurations, higher allocation rates coincided with more frequent collections, supporting allocation churn as the cause.
Third, every collection wakes JSC's parallel marker pool, whose worker threads scan live objects. On the test machine, numberOfGCMarkers was 8, which means 7 HeapHelper threads plus the main thread. Every helper wakes, spins briefly while looking for work, and waits again. Limiting the pool shows how much CPU that cycle uses. GC logging was active in both configurations, and the sched_yield rates came from separate strace windows collected in the same way:
default (8 markers): 1.90% CPU, 7 HeapHelpers at ~17-19 wakes/s, ~520 sched_yield/s
markers=2: 1.00% CPU, 1 HeapHelper at ~8 wakes/s, ~35 sched_yield/s
Reducing the marker pool didn't materially change idle GC behavior: cadence was 31 vs 30 collections per 30s, reclaimed memory was ~24 vs ~26 MB per collection, and pause times were 4.6-6.6ms with 2 markers vs 2.4-7.5ms with the default 8. This result suggests that an idle Eden collection doesn't benefit from 8 parallel markers. In the two long-lived production sessions, mimalloc's mi-scavenger cleanup thread also used 0.2-0.5% CPU while returning freed memory to the OS.
Related reports
#78969 requires a visibly animated element, while the frames here stay byte-identical. #17148, #19393, #22509, #81353, and #83237 report 100%+ CPU spins rather than the steady idle cost reported here. #83237 also mentions mi-scavenger, so it may involve the same allocation and cleanup behavior.
Reproduction
In one terminal, start Claude with your normal config in an empty non-git directory and leave it at the prompt for ~2 min. In a second terminal, run the CPU command below without GC logging, then run the syscall profile. GC logging adds ~0.2-0.4 percentage points, as explained below. Make sure the test session is the newest Claude process because pgrep -nx claude selects it:
mkdir /tmp/idle-repro && cd /tmp/idle-repro && claude
# whole-process CPU over 30s (CLK_TCK=100, so percent = ticks/30)
pid=$(pgrep -nx claude); t0=$(awk '{print $14+$15}' /proc/$pid/stat); sleep 30; \
t1=$(awk '{print $14+$15}' /proc/$pid/stat); echo "ticks: $((t1-t0)), cpu: $(echo "scale=2; ($t1-$t0)/30" | bc)%"
# syscall profile
timeout -s INT 15 strace -c -f -p $pid
For the GC count, stop the first session and start a separate one in the first terminal as BUN_JSC_logGC=1 claude 2>/tmp/idle-repro/gc.log. Run the counter below in the second terminal:
e0=$(grep -c EdenCollection /tmp/idle-repro/gc.log); sleep 30; \
e1=$(grep -c EdenCollection /tmp/idle-repro/gc.log); echo "$((e1-e0)) Eden GCs in 30s"
On 2.1.240, a typical user config used ~1.5% CPU and ran 30 Eden GCs/30s. A pristine CLAUDE_CONFIG_DIR used 0.3-0.8% CPU and had burstier collections. To reproduce the pristine control, start claude with CLAUDE_CONFIG_DIR set to a separate config directory that contains credentials only, then run the same measurement commands.
BUN_JSC_logGC writes each log line as ~100 small stderr writes and increases CPU by ~0.2-0.4 percentage points. Measure CPU and count collections in separate runs, as shown above.
Suggested fixes
Possible fixes:
- Find and remove allocations that happen while idle. The process allocates and discards roughly 1 MB per event-loop wakeup on average even though the screen doesn't change. The callback profile above identifies a transport keepalive, an lstat sweep, resource-usage polling, and frame-write pacing as places to investigate, but it doesn't prove which callback allocates the objects. Because GC cadence tracked allocation churn in every measured configuration, reducing idle allocation should also reduce the per-second Eden collections, marker-pool wakeups, and scavenger work.
- Reduce avoidable timer wakeups at the prompt. About 9 in 10 idle wakeups happen when a timer of 200ms or less expires. Pristine sessions already spend much of their idle time near 0.3% CPU, showing that lower wakeup rates are possible but not that every timer in a typical config can be paused. Pause timers that don't need to run while idle, and combine remaining work into a slower heartbeat where behavior allows it.
- Use fewer GC marker threads in the CLI. BUN_JSC_numberOfGCMarkers=2 reduced typical-config idle CPU from 1.90% to 1.00% in two otherwise identical instrumented runs, without changing idle GC cadence or pause times. Active workloads still need validation before choosing a default, but the extra markers added overhead while idle.
- Reduce idle file and resource polling. These checks use little CPU individually, but they help keep the event loop awake. Where reliable, use the existing fs.watch thread for git changes and keep a polling fallback if needed.
---
The investigation and writeup for this issue were done by Claude Fable 5.