DSH plugin: idle Web UI burns 50% CPU on constant layout
With no user input and no streaming output, the DSH Web UI still keeps the main thread at about 50%; dragging the window edge to resize makes the window boundary visibly lag behind the mouse, and it is worse during agent streaming. Collecting a 10-second window with CDP: TaskDuration 5119ms / 10000ms = 51.2%, of which layout LayoutDuration 410ms occurred across 1453 instances — about 145 per second, laying out on nearly every screen frame; while in the same window there were only 144 rAF callbacks over 6 seconds (24 per second). The layout frequency being far above the rAF frequency shows layout is not rAF-driven (#6427). Bisecting by pausing animation groups converges the root cause onto two core groups: five absolutely positioned sweeps animating left (layout path) and two background-position shimmers layered on background-clip: text (paint path) — pausing them drops the main thread from 60.4% straight to 2.0%, and layouts from 1443/10s to 10/10s. And the drag jank is a separate mechanism: ResizeObserver callbacks write about 5 inline size styles per resize step, and combined with this profile's expensive style recalculation (10.3ms per pass, about 19× idle), the main thread saturates. This article proceeds as "triage first → the two mechanisms → source-level location and fixes → troubleshooting notes".
Triage first: idle usage and resize jank are two independent mechanisms
Do not treat them as one problem. Idle usage comes from the animations; the resize jank persists whether or not the animations run — the criteria, root causes, and fixes all differ.
| Criterion | High idle usage | Drag-resize jank |
|---|---|---|
| Trigger condition | persistently present with no input and no streaming | appears only when dragging the window edge |
| After pausing all animations | drops sharply (60.4% → 2.0%) | still janky (unrelated to animations) |
| Main-thread hotspot | layout / paint (expensive animation properties) | style recalc (RecalcStyleDuration spikes) |
| Key numbers | layouts ≈145 per second | one restyle 10.3ms (idle 0.55ms, ≈19×) |
| Root-cause side | CSS animation properties on the layout/paint path | ResizeObserver writing inline styles × expensive restyle |
| Fix | left → transform, shimmer → steps() | batch writes / narrow the invalidation scope |
Collection method (a reusable clean measurement)
All numbers are collected via CDP, avoiding "a rough look at the task manager". Take the difference over a fixed window:
Performance.getMetrics() 10-second window difference:
TaskDuration 5119 ms / 10000 ms = 51.2% ← main thread busy
ScriptDuration 311 ms ← JS execution
RecalcStyleDuration 289 ms (1572 calls → 0.18 ms/call)
LayoutDuration 410 ms (1453 calls → ≈145/second)
A trace (devtools.timeline, 6 seconds) corroborates it: Layout 157, UpdateLayoutTree 143, Paint 113, with no BeginMainFrame / DrawFrame events; rAF callbacks only 144 over 6 seconds. A CPU Profile (8 seconds) shows (idle) 4.5s and (program) 3.5s (native rendering work, not JS), and the JS-side hotspots are toBottom 79.8ms, getBoundingClientRect 58.6ms, closest 11.6ms.
Together these data point to one conclusion: the cost is in the rendering pipeline (layout + paint), not in JS logic. That determines the troubleshooting direction — go look at CSS animations, not loops in the code.
Mechanism one: five left sweeps + two background-position shimmers laying out/repainting every frame
In one sentence: the properties these animations change are themselves on the layout or paint path, so every frame must re-lay-out or re-rasterize glyphs, and the main thread is naturally saturated; will-change cannot save it, because what it saves is "a missing compositing layer", not "an expensive property".
Bisection: two animation groups account for nearly all the cost
Pausing group by group with document.getAnimations() + pause(), measured on the same machine (0.1.5-rc.2, Electron desktop shell):
| Group paused | Main thread | LayoutCount / 10s |
|---|---|---|
| Nothing paused (baseline) | 60.4% | 1443 |
8× third-party skin maidAtelierSessionJewelChase | 51.8% | 1493 |
+ dsh-tool-row-sweep and dsh-turn-status-shimmer | 2.0% | 10 |
| Restore all | 61.6% | 1497 |
The reading: this core animation group accounts for about 50 percentage points, and nearly all of those ~145 layouts per second; the third-party skin's own 8 animations are worth only about 8.6 percentage points and produce no layout.
Adding will-change: opacity to the animated rect / its svg changed nothing (65.0% / 64.8% / 64.6%, all within noise) — consistent with "plain per-frame repaint, not a layer promotion". The problem is not a missing compositing layer; it is that the animated properties are themselves on an expensive path.
Source-level location: seven spots, two causes
After checking file by file, these 7 are all of them (line numbers based on master 0d1f50007f):
Layout path — five "row sweeps" (an absolutely positioned 300px band animating left, triggering layout every frame):
| File | animation / @keyframes 0% line |
|---|---|
packages/client/ui-chat/src/client/chat/ReasoningRow.module.css | 44 / 49 |
packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css | 23 / 28 |
packages/client/ui-skill/src/client/SkillRow.module.css | 32 / 37 |
packages/client/ui-tool/src/client/tool/components/ToolRow.module.css | 36 / 41 |
packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css | 110 / 115 |
Paint path — two "status shimmers" (background-position layered on background-clip: text, repainting glyphs every frame):
.turnStatusinChatView.module.css(animationline 111, keyframes 125)retry-shimmerinMessageItem.module.css(animationline 256, keyframes 317)
And a full inventory of @keyframes / infinite: the client package has 38 keyframes and 22 infinite in total, and apart from these 7, all the rest are transform or opacity and trigger no layout (only the terminal view's terminal-cursor-blink changes background-color once per second). So once the sweep / shimmer class of cost is cleaned up, there is no same-class residue left.
Reproduction script (deterministically triggers those five sweeps even on an empty session)
No need to wait for a release, nor to build a session with tool rows — pull the selectors out of the compiled output already loaded in the page and assemble running rows:
const css = [...document.querySelectorAll('style')].map(s => s.textContent).join('\n')
const rules = [...css.matchAll(/([^{}]+)\{[^{}]*animation:2\.6s ease-out infinite [A-Za-z0-9_-]*row-sweep[^{}]*\}/g)]
.map(m => m[1].trim())
const mk = s => {
const el = document.createElement('div')
for (const c of s.match(/\.[A-Za-z0-9_-]+/g) ?? []) el.classList.add(c.slice(1))
for (const a of s.match(/\[[^\]]+\]/g) ?? []) {
const [k, v] = a.slice(1, -1).split('='); el.setAttribute(k, v ?? '')
}
return el
}
const host = document.createElement('div'); host.id = 'sweep-probe'
host.style.cssText = 'position:fixed;left:0;top:0;width:720px;z-index:99999'
for (let i = 0; i < 60; i++) for (const sel of rules) {
const parts = sel.replace(/:after$/, '').trim().split(/\s+/).map(mk)
if (parts[1]) parts[0].appendChild(parts[1])
host.appendChild(parts[0])
}
document.body.appendChild(host)
// then collect LayoutCount / TaskDuration with CDP's Performance.getMetrics as usual; clean up with host.remove()
This builds 300 running rows (5 selectors × 60). Measured on 0.1.5-rc.1: 107.6 layouts/s, 1607ms of main-thread tasks over 5 seconds; measuring once with and once without the probe isolates those five sweeps.
One detail: lightningcss adds a CSS-module hash to animation names, so on a real machine you see names like
lcKema_dsh-reasoning-row-sweepando3BgMG_dsh-tool-row-sweep. Whatdocument.getAnimations()hands you is the hashed name, so matching by suffix (/sweep|shimmer/) still works during bisection.
Fix one: make the sweeps transform, make the shimmers steps()
Idea: keep the visuals exactly the same and merely move the animated properties off the main-thread path. Replace the sweep's "animated left" with "a non-repeating background tile + transform"; for the shimmer, just switch the timing function to steps() to cut the per-cycle repaint by an order of magnitude.
The community branch fix/client-composited-row-animations (commit b09ba216e, based on 0d1f50007f) does two things:
- Sweeps (5): the pseudo-element becomes
width: 100%, with the 300px band as a non-repeating background tile (background-size: 300px 100%; background-repeat: no-repeat), and the keyframes becometransform: translateX(-300px)→translateX(100%). The containing block is unchanged, so one band-width of travel still equals one row width — the visual semantics are unchanged. - Shimmers (2): only the timing function becomes
steps(10, end);background-positionandbackground-clip: textare both untouched.
Deliberately preserved design elements: no will-change added; gradients, the 2.6s ease-out, keyframe percentages, and prefers-reduced-motion all unchanged.
Measured A/B (applying the patch directly to the local client bundle)
Assembling 300 running rows from the CSS selectors actually injected into the page, with the probe attached to the real page, 5-second windows per step:
| State | Layouts/s | RecalcStyle/s | LayoutDuration | TaskDuration |
|---|---|---|---|---|
| Original | 107.6 | 120 | 327ms | 1607ms |
| After the patch | 0 | 7.2–8.2 | 0 | 146–163ms (two runs) |
| Idle baseline | 0 | 0 | 0 | 4–5ms |
Equivalence verification (unchanged visuals are a hard requirement)
Pausing the animation at fixed progress and reading the computed ::after values on the real CSS: tx = -300 / -34.45 / 198.12 / 457.06 / 642.99 / 720 px (at 0 / 0.15 / 0.3 / 0.5 / 0.7 / 0.9 of the cycle), within ≤0.015px of the original left trajectory; all five @keyframes …row-sweep are transform, and the left version is 0.
The repo's own gates also passed: pnpm run test:gui all green (391 files / 5579 cases / 0 failures); DSH_SNAPSHOT=replay pnpm run test:web and a control run of "revert those 7 files to 0d1f50007f and rebuild" produced byte-identical 15 failing files (macOS sandbox rejecting PTY, dev:web HMR, llm-replay fixtures not fully consumed due to a front-loaded timeout, goldens recorded on Linux) — zero new failures, zero golden drift.
The same pattern in a third-party plugin: thinkShimmer
While A/B testing third-party plugins on his machine, one reporter found that better-display's two background-position shimmers (thinkShimmer) are the same pattern: muting them drops main-thread JS from 723ms/10s to 530ms (−27%). The cheapest fix of the same kind carries over directly — just switch the timing function to steps(10, end), a one-line change, with background-clip's rendering untouched.
Another piece of corroboration comes from the third-party skin maid-atelier: it had hit the same trap early on (writing inline styles + forcing layout every frame, about 51% idle main thread), and after the author fixed it by "cutting per-frame writes", the measurement dropped to 3.3% (1453 layouts/10s → 2). The idea "animations should only touch compositor properties and not write styles" holds for both the host and third-party plugins.
Mechanism two: resize jank is ResizeObserver inline style writes × expensive restyle
In one sentence: on resize, each ResizeObserver callback writes inline size styles, and every write invalidates the subtree's style; this profile has 178 stylesheets / about 8.7k rules, so Blink's recalculation scope reaches far beyond the changed node, and "N writes × expensive recalculation" saturates the main thread while the window edge cannot keep up with the mouse.
Measured with a real window resize (Win32 SetWindowPos, 20 width steps @60ms, 1920 → 1760px):
| Idle | During resize | |
|---|---|---|
| Median frame interval | 6.1 ms (164 fps) | 12.1 ms (83 fps) |
| Frame interval p95 | 6.2 ms | 90.8 ms |
| Frames > 33 ms | 3 | 16 (23%) |
| Main thread | — | ~100% (1630 ms / 1600 ms) |
Cost breakdown per sweep (20 steps):
RecalcStyle: 130 calls × 10.3 ms — idle it is 0.55 ms/call, i.e. about 19× more expensive during resize;- layout is cheap (about 40 ms total): what is expensive is style recalculation, not layout.
Who is writing styles during resize
Using a setProperty hook to catch the writer, the result is that they are all ResizeObserver callbacks writing inline size styles, about 5 writes per resize step:
div.wSkVaW_root→--dsh-conversation-column-width,--dsh-chat-user-width(the core conversation column)div.P3OORG_panel→width: 864px(the core right panel,data-sidebar-right-panel)div.dshwv-root→inset+--dshw-scale(a floating component)syncHeight(skin-center / web-all) → element height
Single-factor isolation: no single factor fixes it
Excluding them one at a time within the same sweep:
| Variant | Main-thread time |
|---|---|
| Original | 2014 ms |
backdrop-filter: none !important applied to * | 1654 ms (−18%) |
| Pause all animations | 1567 ms (−22%) |
| Disable the skin stylesheet | 1504 ms (−25%) |
No single factor fixes it: with the skin disabled a single restyle drops to 2.4 ms, but the number of calls grows about 2.4× (130 → 311), and the main thread is still around 90%. The fix direction is "reduce the number of writes and narrow the invalidation scope", not "turn off some feature":
- Batch the writes: write once per frame (merge several ResizeObservers' writes into one rAF / one task) instead of one write per callback;
- Set the CSS variables on a common ancestor: make the invalidation scope of the variable change converge on one shared containing block rather than triggering independent subtree recalcs;
- Switch to container queries: let layout be driven by container size, avoiding the "measure → write inline style → measure again" loop;
- Shrink the stylesheet size / reduce selector complexity: 178 stylesheets and about 8.7k rules are the amplifier of "one recalculation is expensive".
Note: this mechanism is independent of mechanism one, and the animation patch does not touch it — the reporter explicitly labels this in his conclusion.
Troubleshooting notes
First principle: separate "high usage caused by animations" from "jank caused by resize" first, then quantify with CDP — do not guess by feel whether it is a code loop or the rendering pipeline. The numbers in this report are more valuable than its conclusions.
- Look at the ratio of layout frequency to rAF frequency first: layouts ≈145 per second versus rAF only 24 per second — layouts far above rAF means it is not rAF-driven, so go look at CSS animations / style invalidation (#6427).
- Bisect with
document.getAnimations(), not by editing code one piece at a time:(#6427)jsdocument.getAnimations() .filter(a => /sweep|shimmer/.test(a.animationName || '')) .forEach(a => a.pause()); // main thread 60% → 2%, layouts 1443 → 10 - Do not treat "add
will-change" as a universal cure: it cannot save "the animated property itself is on the layout/paint path" (#6427). - Watch the "idling cost" of
infiniteanimations: of the whole package's 22infiniteanimations, the rest aretransform/opacity, and only these 7 land on expensive paths. Clean those up and it is clean — which is why it is worth inventorying once (#6427). - For resize, look at
RecalcStyleCount, notLayoutCount: in this mechanism layout is cheap and style recalculation is the bottleneck (10.3ms vs 0.55ms) (#6427). - Third-party plugins are a common amplifier, but do not assume they are the only cause: here disabling the skin only recovers about 25%, with 29% of persistent usage remaining; do the A/B once on a clean profile and once on a plugin-loaded profile (#6427).
- Check the probe's applicable boundary: that 107.6 layouts/s is a "probe-row" load (the CSS and selectors are real compiled output, but the load is constructed), and is not the same as sampling "a session run naturally"; for the latter you need to re-measure on a real machine by comparing before and after pausing (#6427).
- Set reasonable expectations for the fix: the shimmer only drops by an order of magnitude and style recalc is still 60 per second, so the post-fix value should land between 2.0% and 60.4%, not at 2.0% (#6427).
When troubleshooting this kind of problem, use DSH Plugin Hub's installed list to disable / uninstall frontend plugins one by one for A/B — here the third-party skin accounts for about half the idle cost, which is a good first comparison; confirm which plugins are writing styles and which are merely decorative before touching the core animations.

Source: Discussion #6427.
FAQ
It is not rAF-driven; it is **CSS animations walking the layout path every frame**. Bisection measurements show that pausing the two animation groups dsh-tool-row-sweep (2600ms, infinite) and dsh-turn-status-shimmer (1800ms, infinite) drops the main thread from 60.4% to **2.0%** and LayoutCount from 1443/10s to **10/10s**. They account for nearly all of those ~145 layouts per second (Discussion #6427).
Because the problem is not "a missing compositing layer" but that **the animated properties themselves are on the layout/paint path**. The sweep animates left on an absolutely positioned element (triggering layout every frame), and the shimmer animates background-position layered on background-clip: text (repainting the glyphs every frame). will-change only promotes layer creation; it cannot rescue "the animated property itself is expensive" — measured 65.0% / 64.8% / 64.6%, all within noise (Discussion #6427).
No, they are **two independent mechanisms**. The idle usage comes from those two animation groups; the resize jank persists **whether or not the animations are running** (consistent with the user report), because ResizeObserver callbacks write about 5 inline size styles per resize step, and that restyle is far more expensive (10.3ms per pass vs 0.55ms idle, about **19×**). An animation patch cannot fix the resize case (Discussion #6427).
Yes, and deterministically. The stylesheets in the page are already the real compiled output, so you can pull the sweep selectors out of them and assemble running rows (the "reproduction script" section gives the complete code: 5 selectors × 60 = 300 running rows). Note that lightningcss adds a CSS-module hash to animation names, so on a real machine you see names like lcKema_dsh-reasoning-row-sweep; during bisection, matching by **suffix** (/sweep|shimmer/) still works (Discussion #6427).
No. 2.0% is the result of **pausing all** sweeps and shimmers; the shimmer fix merely cuts the per-cycle repaint by an order of magnitude, and background-position's style recalc is still 60 per second. The expected post-fix value should land **between 2.0% and 60.4%**. Getting the shimmer entirely off the main thread would mean converting it to a composited overlay layer — that changes how the status text is rendered and has not been done yet (Discussion #6427).
Related Terms
- TaskDuration ratio
- `TaskDuration` in CDP's `Performance.getMetrics` is the total time the main thread was busy. Taking the difference over a fixed window (say 10 seconds) and dividing by the window length gives the "main thread utilization". It is more accurate than reading the task manager, because it lets you do a clean before/after-injection A/B on the same machine to quantify the cost of a given animation or plugin.— https://github.com/deepseek-ai/deepseek-harness/discussions/6427
- layout-path animations vs compositor-layer animations
- The animated property determines the cost: `transform` and `opacity` can run on the compositor in their own layer without touching the main thread; `left` / `width` / `height` / `top` trigger layout, and `background-position` / `background-color` trigger paint, all executed per frame on the main thread. Replacing the sweep's `left` with `transform` and switching the shimmer's timing function to `steps()` is essentially "moving the animation off the main-thread path".— https://github.com/deepseek-ai/deepseek-harness/discussions/6427
- ResizeObserver style writes × expensive restyle
- On resize, every `ResizeObserver` callback writes inline size styles, and each write invalidates the subtree's style and triggers a style recalculation. When a page has 178 stylesheets / about 8.7k rules, Blink's recalculation scope reaches far beyond the changed node, so "N writes × expensive per-recalc cost" saturates the main thread and the window edge cannot keep up with the mouse. The fix is batching (one write per frame), setting CSS variables on a common ancestor, or using container queries to narrow the invalidation scope.— https://github.com/deepseek-ai/deepseek-harness/discussions/6427
- layout thrashing (forced synchronous layout)
- When you alternate "writing DOM/styles" and "reading geometry (`getBoundingClientRect`, `offsetHeight`, etc.)" within the same task, the read forces the browser to immediately flush all pending style and layout changes, or it cannot return a correct value. Alternating at high frequency forces layout repeatedly and crushes the main thread. The JS-side hotspots in this report are exactly `getBoundingClientRect` (58.6ms / 8s) and `toBottom` (79.8ms / 8s).— https://github.com/deepseek-ai/deepseek-harness/discussions/6427