[Bug]: @formkit/auto-animate position polling burns ~24% CPU permanently on an idle app
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
- Launch the desktop app (Linux, Electron 41.9.1) and open a project with a populated thread list in the sidebar.
- 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).
- Leave the window visible and foregrounded, and touch nothing.
- Measure CPU over a long window rather than a short
topsample, since the load fluctuates:
ps -eo pid,ppid,commto find the tree, then sampleutime+stimefrom/proc/<pid>/statacross 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 (getCoords → getBoundingClientRect, 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
autoAnimateto 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 calldisable()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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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