Claude Code Desktop: WindowServer at ~47% CPU while a response streams (deep CoreAnimation layer tree re-walked at 120 Hz)
Summary
While the Claude desktop app generates a response, macOS WindowServer runs at about 47% of one CPU core. The app window is otherwise cheap: with the app idle and the same window visible, WindowServer sits at 3-6%.
A stack sample of WindowServer shows the time goes into re-walking the Claude window's CoreAnimation layer tree, which nests about 50 levels deep, once per display refresh (120 Hz on this machine).
Environment
- Claude desktop 1.52386.3
- macOS 26.6.2 (25G83)
- MacBook Pro, Apple M3 Pro, 14-core GPU
- Built-in Liquid Retina XDR display, 1512x982 points @ 3024x1964, 120 Hz
Reproduce
- Open the Claude desktop app with one window visible and nothing else on the space.
- Record
WindowServerCPU every 2 seconds. - Send a prompt that produces a long reply.
- Compare the CPU while the reply streams against the CPU when the app is idle.
Measurement
One continuous 2-minute trace. Nothing else changed. No other app window was on screen.
2s 48.5% previous reply still streaming
6s 34.0%
8s 8.0% streaming stops
12s 3.0%
...
54s 3.0% idle
58s 3.0%
60s 5.0%
62s 37.0% new reply starts streaming
64s 50.0%
73s 50.5%
87s 48.0%
101s 48.0%
105s 27.5% reply ends
111s 2.5%
121s 3.0% idle
Idle: 3-6%. Streaming: 41-51%.
Stack sample
sudo sample WindowServer 10. All other WindowServer threads are idle; the render-server thread is blocked in mach_msg for 6440 of 6472 samples. All the time is on the main thread:
CGXRunOneServicesPass
post_port_data
non_coalesced_timer_handler
run_timer_pass
update_display_callback(void*, double) 2545
CGXUpdateDisplay 2544
WS::Updater::UpdateDisplays 2050
WS::Updater::prepare_coreanimation 1893
ca_prepare_begin_window_update(CGXWindow*) 1657
WSSessionConnectionPerformWithPluginOwner 1654
CARenderUpdateAddContext2 1487
CA::Render::Update::add_context 802
CA::Render::Updater::prepare_layer0 \
CA::Render::Updater::prepare_sublayer0 / ~50 levels of recursion
CA::Render::Updater::prepare_portal
One window accounts for this. The ca_prepare_begin_window_update branch with 1657 samples is the Claude window. Every other ca_prepare_begin_window_update branch in the sample has 20 samples or fewer.
Attribution
- Hiding the Claude window (Command+H) during streaming takes
WindowServerfrom 47% to 1.5%. Restoring it returns to 47%. - Hiding every other application on the space changes nothing. Nine applications were eliminated one group at a time.
- A second Electron application was on screen for one round and contributed nothing measurable. This is specific to the Claude window.
- A shorter conversation does not help. A fresh session with a two-message transcript measured 51.2%.
- A smaller window costs less, consistent with fewer layers in the tree.
Ruled out
Scaled display resolution (tested at native 1512x982 @ 3024x1964), video playback in another app, HID event rate (2.3 events/s), accessibility zoom and color filters (all off), EDR (headroom 1.0, inactive), Stage Manager (off), external displays (none), GPU saturation (10% device utilization), WindowServer log errors (none in 20 minutes).
Expected
A streaming text animation should not require WindowServer to re-walk a 50-level layer tree at 120 Hz. Flattening the animated region into fewer composited layers, or confining the animation to its own layer, should remove most of this cost.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Independent reproduction on materially different hardware, plus stack evidence from a different phase of the same pipeline than the one in the original report.
Short version: this is still present in Desktop 2.2553.1 on macOS 27.0, and on a weaker GPU driving a higher-refresh external display the cost roughly doubles — WindowServer reaches 55% of a core with only the working spinner animating, before any token streaming.
Environment
| | This report | Original report |
|---|---|---|
| Claude Desktop | 2.2553.1 | 1.52386.3 |
| macOS | 27.0 (26A428) | 26.6.2 (25G83) |
| SoC / GPU | M1, 7-core GPU | M3 Pro, 14-core GPU |
| Display | External 4K, 3840×2160 HiDPI @ 160 Hz, main display, mirrored to built-in 2560×1600 | Built-in XDR, 3024×1964 @ 120 Hz |
| Electron / Chromium | 44.2.0 / 152.0.7977.76 | — |
So: 1.5× the refresh rate, 1.6× the framebuffer pixels, half the GPU cores, and a mirrored second display pipeline.
Measurements
Measured as real CPU-seconds consumed over a fixed 30 s window (
ps -o cputime=delta) rather than instantaneous%CPU:| Process | Working spinner animating, no token streaming |
|---|---|
| WindowServer | 55.3% of a core |
| Claude Helper (GPU process) | 15.4% |
| Claude Helper (Renderer) | 15.7% |
Supporting signals:
IDLEW= 0 on the GPU helper — it never returns to idle. This is sustained vsync-paced rendering, not a timer/wakeup busy-loop (which distinguishes it from #74944, whose signature was 690–740 idle wake-ups/sec).cpu_resource.diagon this machine while Claude was open: "90 seconds cpu time over 178 seconds (51% cpu average), exceeding limit of 50% cpu over 180 seconds."⚠️ One caveat on my own numbers: an earlier reading of 91% / 44% was taken while post-OS-upgrade background work (Spotlight reindex,
mobileassetd, CoreSimulator runtime installs,dasd) was still saturating the machine. Disregard that pair. The 55.3% / 15.4% figures above were taken after verifying that all of it had gone quiescent.Difference worth noting: the trigger threshold is lower here
The original report finds idle at 3–6% and only streaming at 41–51%. On this configuration I measure 55.3% with no token streaming at all — only the Code session's working spinner animating while a shell command ran. If the cost scales with refresh rate × framebuffer area as the layer-tree explanation predicts, then a 160 Hz 4K panel on a 7-core GPU crosses into "system-wide lag" territory from animations far cheaper than streaming text. That matches what the user experiences: the machine is unusable whenever Claude is open and doing anything at all.
This also suggests the original report's finding that external displays are not a factor may not generalise. On an M3 Pro with 14 GPU cores there is enough headroom to absorb it; on a 7-core M1 driving a mirrored 4K@160 Hz main display there is not.
Stack evidence from the render phase (complements the prepare-phase stack above)
The original report's stack lands in the prepare/commit phase —
prepare_coreanimation→ca_prepare_begin_window_update→CA::Render::Updater::prepare_layer0/prepare_sublayer0, ~50 levels deep. That is the CPU-side layer-tree walk.From
/Library/Logs/DiagnosticReports/WindowServer_*.cpu_resource.diag(Microstackshots) on this machine, 12 of 13 samples land in the per-frame display update, but in the render phase instead:Also appearing inside the recursion:
Why this matters beyond the tree walk:
ImagingNodeis a CoreAnimation filter layer, and eachretain_surfaceallocates an offscreen buffer. So the nested layers are not only being walked every refresh — they are being re-rendered as ~25 stacked filter passes with offscreen surface allocation, at 3840×2160, every frame. That is consistent with a detail I could not otherwise explain: during the worst period WindowServer's footprint reached 1311.97 MB, against roughly 90 MB when idle.Caveat: that report contains only 14 samples, so treat the stack as indicating where the work is, not as a precise profile. The 55.3% figure is from direct measurement and does not depend on it.
Taken together with the prepare-phase stack already in this issue, the nesting appears to cost twice per frame — once walking the tree, once rendering the filter chain into offscreen surfaces.
On mitigations
I did not independently re-test the ⌘H or window-size workarounds reported above, so I am not claiming to have confirmed them — I am only adding the measurements and stacks described here.
What I did check: no user-facing mitigation is exposed by the app. The app's
PreferencesandLocal Statecontain no GPU, rendering, animation or transparency keys. Related: #25575's reporter documented thatprefersReducedMotion: truedoes not suppress the animation, and thestatusBarAnimationEnabled: falsesetting requested there still does not exist.Suggested fix, in the order that would help this configuration most
ImagingNodechain above suggests specifically collapsing the nested filter layers, which are the expensive ones.prefers-reduced-motionfor this animation, and add the opt-out requested in #25575.Related closed reports
Same signature, all closed without a fix, spanning March → July 2026 and Desktop 1.1.x → 1.22209.0: #78770, #33526, #32020, #74944, #25575. With this report the behaviour is confirmed to have survived into 2.x on macOS 27.
Reproduction
%CPU:dasd,mds_stores,mobileassetdand Spotlight indexing will otherwise contaminate the reading. This is how I produced the bad 91% pair above.Follow-up to my previous comment — I located the specific CSS rule that produces the nested filter layers, by reading the shipped bundle. The good news: it is already guarded by
prefers-reduced-motion, so the fix is small and a user-side workaround may already exist.Source:
Claude.app/Contents/Resources/app.asar→.vite/renderer/main_window/assets/MainWindowPage-CVdxcOi4.css(Desktop 2.2553.1).The animated element
Why this lands in
CA::OGL::ImagingNode-webkit-mask-imagecombined with-webkit-mask-clip: textforces CoreAnimation to build a mask/filter layer, which is exactly what anImagingNodeis. The animated pseudo-element isinset: 0 -300% 0 0— four times the element width — and it animatestranslatecontinuously, so the masked filter layer is re-rendered every display refresh. Each masked text run gets its own clone element, so instances nest and stack.That ties the CSS directly to the render-phase stack in my previous comment:
CA::OGL::render_layers→ImagingNode::render→LayerNode::apply, repeating ~25 levels, withImagingNode::retain_surface/FlattenNode::retain_surfaceallocating an offscreen buffer per level, at 3840×2160, every frame.The guard already exists
Every one of the six
infiniteanimations in this stylesheet is already guarded, two ways — a media query and an attribute selector:The same pattern covers
.cds-dot(dot-pulse),.cds-flipbook, the threeSkeletonGroupanimations (cds-skeleton-breathe,cds-skeleton-sweep),.cds-chip-in, and.cds-resolve-square/.cds-resolve-check.But there is no way for a user to reach either guard. Searching the bundle:
data-reduce-motionappears 220 times — all of them CSS selectors — whilereduceMotion,reducedMotionandreduce_motionappear 0 times in the renderer JavaScript. So nothing in the app ever setsdata-reduce-motion=true, and the app exposes no setting for it (PreferencesandLocal Statecontain no animation, rendering or GPU keys). The only remaining route is the OS-level accessibility setting, or forcing the media query at the Chromium level.Suggested fix, smallest first
cds-shimmer-text-band's repaint rate so it does not scale with panel refresh. At 160 Hz this animation is driving ~55% of a WindowServer core on a 7-core M1; at 30–60 fps it would be a fraction of that, and visually identical to most users.data-reduce-motion=trueis already fully wired in CSS — surfacing it as a setting, or setting it from the OS reduce-motion preference, would cost close to nothing and give affected users an immediate opt-out. This is also what #25575 asked for asstatusBarAnimationEnabled: false.background-positionon a single non-masked layer, or compositing the band as one flattened layer, would remove the nestedImagingNodechain rather than just running it less often.animation-play-stateis already parameterised via--cds-shimmer-text-play-state, so the hook is there.Caveat on the workaround
I have not yet verified that Chromium's
--force-prefers-reduced-motionactually reaches the renderer in this Electron build (44.2.0 / Chromium 152.0.7977.76) — only that the CSS guard it would trigger exists and targets the right element. If it does work, it is a clean app-scoped workaround that requires no display or system changes. I will follow up with measurements either way.Measured follow-up, as promised. Forcing the existing
prefers-reduced-motionguard works, and it is a large win.Launching Desktop 2.2553.1 with Chromium's
--force-prefers-reduced-motion, same machine and same display configuration as my earlier comment (M1 7-core GPU, 3840×2160 HiDPI @ 160 Hz, mirrored), 30 s measurement windows, real CPU-seconds consumed:| Process | Default launch |
--force-prefers-reduced-motion| Change ||---|---|---|---|
| WindowServer | 55.3% | 29.2% | −47% |
| Claude Helper (GPU) | 15.4% | 2.3% | −85% |
| Claude Helper (Renderer) | 15.7% | 4.9% | −69% |
No display, refresh-rate, mirroring or system accessibility settings were changed — the flag is scoped to the Claude process alone. This confirms that the
@media (prefers-reduced-motion: reduce){ … display:none }guard identified in my previous comment is on the right element, and that the shimmer animation accounts for the large majority of the GPU-helper cost and roughly half of the WindowServer cost on this configuration.This is the strongest argument for suggestion 2 in my previous comment: the guard is already written and already correct. Wiring
data-reduce-motion=trueto a setting, or honouring the OS reduce-motion preference, would hand every affected user this result with no rendering work at all.Practical notes for anyone trying to reproduce this
open -a Claude --args --force-prefers-reduced-motionsilently drops the arguments if Claude is already running — macOS only passes--argson launch. Quit fully with ⌘Q first (closing the window is not enough).``
`/Applications/Claude.app/Contents/MacOS/Claude --force-prefers-reduced-motion
ps` output for the helpers will make it look like the flag was ignored. It was not — judge by the numbers.Checking
%CPU, and verify no OS background work is competing (see the reproduction steps in my first comment).Two things this does not settle
dasdloop was consuming a full core on this machine, so I cannot attribute the remainder — the true post-flag figure is likely lower than 29.2%, and I have no clean reading yet for what is left. I will not speculate about the residual.