Skip to content

web: sustained ~50% CPU and maxed GPU while live-rendering a streaming session #49552

Open

Description

@Idsa0

Summary

The v2 web client sustains a large fraction of a CPU core and keeps the GPU at its maximum clock for the entire duration of a streaming session, then drops to ~0 when the session is idle. The load is disproportionate for streaming text and scales with session content size; it appears to come from per-event timeline re-rendering without batching or throttling.

Environment

  • opencode version: 2.0.5 (channel: latest; 2.0.6 does not change this code path — I audited the full v2.0.5...v2.0.6 diff before reporting)
  • OS: Linux 7.2.6-arch2-1 (Arch Linux, x86_64), KDE Plasma on Wayland, hybrid laptop (Intel UHD iGPU + RTX 3050); the browser composites on the iGPU
  • Web client: Firefox 156.0 (opencode web UI served locally by opencode serve, paired via opencode pair)
  • Install/channel: Arch Linux package (pacman), v2 release channel
  • Active plugins: none

Reproduction

  1. Start the background service (opencode service start) and open the web client in a browser using the credentials from opencode pair.
  2. Stream a long agent turn in the session that the web tab displays: any long response, amplified by large tool outputs (e.g. a cat of a multi-MB file).
  3. While streaming, watch:
    • the Firefox content process CPU (ps -eo pid,pcpu,args | grep 'firefox -contentproc'), and
    • the iGPU: KDE GPU-load widget, or cat /sys/class/drm/card1/gt_cur_freq_mhz (compare against gt_max_freq_mhz).
  4. Let the session go idle; observe the load fall back to baseline.

Consistently reproducible: the load tracks streaming activity 1:1 (high while any event for the displayed session is being rendered, ~0 when idle).

Expected Behavior

Rendering the live timeline while a session streams should cost on the order of a terminal streaming text: brief, batched work per chunk, with the GPU and CPU near idle between bursts.

Actual Behavior

While the displayed session streams (measured on 2.0.5, repeated runs):

State Firefox content CPU iGPU (Alder Lake UHD, max 1300 MHz)
Web tab closed ~0 300–600 MHz (idle)
Web tab open, session idle ~0 near-0 utilization
Web tab open, session streaming ~35–50% of one core, nearly constant pinned at 1300 MHz (max)

The KDE GPU widget correspondingly shows the iGPU near 100% for the whole duration of the stream, even though no interaction with the page is happening.

Additional Context

Related issues:

Static analysis of the 2.0.5 web bundle (served assets) shows the mechanism:

  • The web client subscribes to the server's global event stream (with stall-detection abort + reconnect/backoff).
  • The session route renders the timeline as a virtualized list. Each streamed event mutates the timeline DOM, which re-triggers a chain of observers that schedule requestAnimationFrame work:
    • ResizeObserver → rAF row re-measure (resizeItem / height recalculation) on the virtualized list,
    • MutationObserver on the timeline container (childList/subtree/characterData) → rAF scroll/reveal reconciliation,
    • message-count change → rAF scroll-position restore,
    • a history-preload rAF chain that keeps firing while the viewport is not full.
  • I found no batching, coalescing, or idle-throttling of these re-render paths in the 2.0.5 bundle (all setInterval sites are legitimate: a 1s shell-output poller while a shell tool runs, a retry countdown; no unconditional idle loop exists — the load strictly correlates with incoming events).

Notes:

  • The cost scales with content size: the measurements above were taken on a session containing several multi-MB tool outputs; even so, ~50% of a core + pinned-max GPU for a text stream looks well above the expected cost.
  • 2.0.6 audit: the only session-timeline changes in v2.0.5...v2.0.6 are attachment-card display (SessionUserAttachmentReference); no render-pipeline changes.
  • Workaround: closing the web tab (or navigating away from the active session) stops the load immediately.

Activity

  1. github-actions commented on Sep 17, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    If these are separate from what you're reporting, please clarify how this differs. Otherwise, adding detail to one of the existing open issues may be more productive.

  2. Idsa0 commented on Sep 17, 2026

    @Idsa0
    Author

    I compared each of the suggested duplicates:

    Keeping this open as the symptom-level report for the v2 web client (GPU measurements + bundle analysis); happy to close it if you'd rather consolidate tracking under #48434.

  3. Lightningxxl commented on Sep 19, 2026

    @Lightningxxl

    Adding a data point from Desktop (Electron) 1.18.31 on macOS that supports the per-event re-render hypothesis and separates it from the idle ResizeObserver loop (#38296):

    • With 2-3 concurrent streaming sessions, the Desktop renderer bursts to ~178% CPU (near 2 cores) and the GPU helper to ~40-60%; load tracks streaming activity and falls when sessions idle.
    • Crucially, during streaming the ResizeObserver error rate drops (mean 2.4-3.4/min) vs when idle (mean 9.8/min, peak 82/min). So the streaming-time load is dominated by real re-render work (this issue), while the idle-time load is the self-sustaining ResizeObserver loop (Electron renderer unresponsive / crash due to ResizeObserver loop storm (layout thrashing) #38296). The two regimes are separable in logs by error rate.
    • Consistent with "per-event timeline re-rendering without batching": load scales with number of concurrent sessions and event rate.

    Suggests the batching/throttling fix here and the ResizeObserver guard in #38296 address two distinct cost components; fixing only one leaves the other.

  4. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed measurements. I couldn't reproduce this in our automated sandbox: it has no desktop browser and no GPU, so it can't show the GPU and compositing load you describe. A maintainer will need to check it with a real browser profile.

    The closest existing report is #48434, which is still open. It found that timeline row reconciliation does a deep-equality comparison on every streamed delta, and that cost grows with session size. Its fix PR, #48435, was closed without being merged. That may be part of what you're seeing, but your report also covers the GPU load and the observer/requestAnimationFrame work, so this issue stays separate.

    A Firefox Profiler recording (https://profiler.firefox.com) taken while a session streams would help a maintainer narrow it down.

  5. Idsa0 commented on Oct 31, 2026

    @Idsa0
    Author

    Update (v2.2.5) — residual isolated to always-on working-state animations; cost scales with display refresh rate

    Re-tested on v2.2.5 (Firefox, Linux). After isolating the cost, the residual is a different, continuously-running cost than what #60489 addressed.

    What it is / isn't. Two PRs shipped in the 2.2.3 release to address other slices of this symptom — #60489 (per-delta timeline row-equality; closes #48434) and #60481 (stops the ResizeObserver loop errors while rows grow; explicitly references this issue). Both cut per-frame/streaming overhead, but neither changes the compositor frame rate, and neither touches this cost — the remaining working-state GPU load is animation-driven. Cleanest isolation, on the same build and same display rate: animations off → working-state GPU ~0 %; animations on → sustained (~40 % at 60 Hz, ~70 % at the 144 Hz baseline).

    Mechanism (v2.2.5 web bundle). The working/thinking state carries a set of always-on CSS animations. The dominant one is the status label rendered by [data-component="text-shimmer"], which draws the label one animated span per character, each running an infinite text-shimmer-sweep keyframe animating background-position under background-clip:text (will-change: background-position) — a paint property, forcing a repaint each frame. It's accompanied by other working-state indicators, e.g. the busy tab mark [data-titlebar-tab][data-status=busy] [data-mark=busy] rendering a loader-v2 spinner (transform: rotate, compositor-cheap), plus pulse/caret opacity animations.

    Measured (Firefox Profiler, working state, no text streaming):

    144 Hz 60 Hz
    Compositor vsync ~143 /s 59.5 /s
    Full paints ~142 /s ~55 /s
    WebRender frame builds 135.6 /s 59.2 /s
    CSS animations sampled / frame ~25 ~24
    Content style/paint invalidations 125.0 /s 63.6 /s

    Same per-frame animation count at both rates; every compositor/paint metric scales linearly with display refresh rate (vsync-capped), and GPU utilization follows: ~40 % at 60 Hz → ~70 % at the native 144 Hz baseline (peaking while streaming, when the paint-heavy shimmer is active), reproducing the original high-GPU symptom at the native rate.

    Recordings: 144 Hz — https://share.firefox.dev/4if8h1v · 60 Hz — https://share.firefox.dev/4zqm0Zq

    Same persistent-animation GPU-burn class as #31664, scoped to named elements, and a real cost at any refresh rate (≈2.4× at 144 Hz vs 60 Hz).

    Shipped mitigation: prefers-reduced-motion / data-motion="reduce" sets animation: none on these elements (which is why disabling animations drops GPU to ~0 %).

    Suggested fixes:

    1. Render the shimmer as a single compositor-friendly sliding highlight (one transform/mask) instead of per-character background-position animations.
    2. Trim the count of always-animating working-state indicators.
    3. Pause them when the reasoning block is collapsed or the tab is backgrounded.

    Keeping this open as the element-level report for the v2 web client.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions