pingdotgg / pingdotgg/t3code

[Bug]: @formkit/auto-animate position polling burns ~24% CPU permanently on an idle app

Open
#4,693 0 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
23k
Forks
5.9k
Avg merge
11h 14m
Merged PRs (30d)
357

Description

Before submitting
  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.
Area

apps/web

Steps to reproduce
  1. Launch the desktop app (Linux, Electron 41.9.1) and open a project with a populated thread list in the sidebar.
  2. Make the app completely idle — no agent running, no streaming, and provider/repo update polling disabled (so #4182-style subprocess churn is not a factor).
  3. Leave the window visible and foregrounded, and touch nothing.
  4. Measure CPU over a long window rather than a short top sample, since the load fluctuates:
    ps -eo pid,ppid,comm to find the tree, then sample utime+stime from /proc/<pid>/stat across the whole tree for 60s.

The load is steady and reproduces across app restarts and across process-tree PIDs.

Expected behavior

An idle app with nothing running should sit at roughly 0% CPU. Nothing is animating on screen and no work is in flight, so there should be no sustained timer, layout, or paint activity.

Actual behavior

The app burns ~26% CPU permanently while completely idle (8-core machine), essentially all of it in the renderer.

A DevTools performance trace shows the renderer is 74.2% idle, and the busy remainder is dominated by (program) native time rather than app JS — i.e. forced layout and observer machinery, not application logic. In a 7.0 s trace:

Signal Count Rate
TimerFire 1714 245/s
setTimeout @ 250 ms, singleShot 851 122/s
requestIdleCallback 854 122/s
IntersectionObserver recomputations 722 103/s
PageAnimator::serviceScriptedAnimations 13.5 ms total

Note the last row: this is not animation-frame work. It is timer-driven.

Resolving every hot frame through the bundle's sourcemap points at a single dependency: @formkit/auto-animate@0.9.0.

Root cause

auto-animate polls the position of every element it manages, forever, independently of whether anything changed. From its index.mjs:

function poll(el) {
  setTimeout(() => {
    intervals.set(el, setInterval(() => lowPriority(updatePos.bind(null, el)), 2000));
  }, Math.round(2000 * Math.random()));
}

function lowPriority(callback) {
  if (typeof requestIdleCallback === "function") requestIdleCallback(() => callback());
  else requestAnimationFrame(() => callback());
}

function updatePos(el, debounce = true) {
  clearTimeout(debounces.get(el));
  // delay = options.duration, which defaults to 250
  debounces.set(el, setTimeout(async () => {
    coords.set(el, getCoords(el));   // forced layout read
    observePosition(el);             // disconnects and builds a NEW IntersectionObserver
  }, delay));
}

So per managed element, every 2 seconds, forever: one idle callback, one 250 ms timer, one forced layout read (getCoordsgetBoundingClientRect, plus getScrollOffset walking the entire parent chain), and a freshly constructed IntersectionObserver.

autoAnimate() calls forEach(el, updatePos, poll, ...), which applies poll to the container and its children, so a list of N rows registers N+1 pollers.

At 122 polls/s and 0.5 polls per element per second, roughly 244 elements are being polled. That is consistent with #3962, which identifies the sidebar thread list in apps/web/src/components/Sidebar.tsx as being wrapped in autoAnimate and reports ~100 thread rows there. #3962 is the same library but a different failure mode (a one-off multi-second block on collapse); this issue is the continuous idle cost, which is why I filed it separately.

Confirmation

autoAnimate() skips poll() entirely when reduced motion is set:

const isDisabledDueToReduceMotion =
  mediaQuery.matches && !isPlugin(config) && !config.disrespectUserMotionPreference;
if (!isDisabledDueToReduceMotion) { /* ... forEach(el, updatePos, poll, ...) */ }

Relaunching with --force-prefers-reduced-motion and re-measuring identically:

Metric (idle app, 60 s window, 8 cores) Baseline Reduced motion Change
Renderer CPU 20.0% 0.4% −98%
GPU process 3.6% 0.0% gone
Server 1.7% 1.1%
Main process 0.8% 0.9%
Tree total (mean) 26.0% 2.3% −91%
Tree total (range) 21.5–38.0% 1.3–3.0%
Renderer syscalls/s 202 3 −98.5%
Renderer voluntary ctx-switches/s 190 6 −97%

The syscall and context-switch collapse is independent confirmation that the ~245 timer fires/s are the mechanism, and the GPU process falling to zero shows the repaint traffic driven by those forced layout reads disappears with them.

That puts @formkit/auto-animate at roughly 24 percentage points of CPU on a fully idle app, about 92% of total usage.

Suggested fix

  • Scope autoAnimate to far fewer containers, and avoid applying it to long lists where every row becomes an independent poller.
  • Or use the controller returned by autoAnimate() and call disable() when a view is collapsed, scrolled out of sight, or otherwise idle, re-enabling on interaction.
  • The per-element 2 s position poll is inherent to the library and does not scale to a few hundred elements, so bounding the element count is the durable fix rather than tuning duration.
Impact

Major degradation or frequent failure

Version or commit

0.0.28

Environment

Linux (Ubuntu, X11, kernel 5.15), 8-core, Electron 41.9.1, t3code 0.0.28 installed via Nix. Reproduced across multiple app restarts.

Logs or stack traces
# Idle app, 60s window, per-process mean/min/max CPU sampled from /proc/<pid>/stat

# BASELINE
    PID   mean%    min%    max%   role
 <rend>    20.0    15.9    32.8   renderer
 <gpu>      3.6     2.9     4.3   GPU process
 <srv>      1.7     0.7    12.1   apps/server bin.mjs
 <main>     0.8     0.3     1.3   electron main
TREE TOTAL  mean=26.0%  min=21.5%  max=38.0%
renderer: syscr/s=202  syscw/s=202  vol-ctxsw/s=190

# WITH --force-prefers-reduced-motion
    PID   mean%    min%    max%   role
 <srv>      1.1     0.3     2.0   apps/server bin.mjs
 <main>     0.9     0.3     1.3   electron main
 <rend>     0.4     0.0     0.7   renderer
 <gpu>      0.0     0.0     0.0   GPU process
TREE TOTAL  mean=2.3%  min=1.3%  max=3.0%
renderer: syscr/s=3  syscw/s=1  vol-ctxsw/s=6

# Trace event counts, 7.0s DevTools trace, renderer process
TimerFire                                    1714   (245/s)
setTimeout timeout=250ms singleShot=true      851   (122/s)
requestIdleCallback                           854   (122/s)
IntersectionObserverController::computeIntersections  722
PageAnimator::serviceScriptedAnimations      13.5 ms total

# TimerInstall stack, 854 occurrences, sourcemapped
#   @formkit/auto-animate/index.mjs:145:22   (updatePos -> setTimeout, 250ms)
#   @formkit/auto-animate/index.mjs:185:34   (lowPriority -> requestIdleCallback)
Workaround

Launch the desktop app with Chromium's reduced-motion switch, which makes autoAnimate() skip poll() and drops idle CPU from ~26% to ~2.3%:

t3code-desktop --force-prefers-reduced-motion

The tradeoff is that intended animations are disabled along with the polling. Setting prefers-reduced-motion: reduce at the OS level has the same effect but applies to every Chromium and GTK app on the machine.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start at autoAnimate usage in apps/web/src/components/Sidebar.tsx and inspect how the sidebar thread list and its rows are wrapped. Reproduce the idle load with the provided process sampling or DevTools trace, then evaluate whether the controller or a narrower animation scope removes the recurring polling. Done means the idle app no longer sustains the reported renderer CPU cost without disabling intended animations.

Written by the indexing model from the issue text.

Assessment

Tech stack
electron, typescript
Domain
desktop, frontend, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.