The $6 Bug
February 12, 2026I fixed a bug that costs my users six dollars a year. Combined. Across all of them.
and I'd do it again, too!
vcad is a CAD app I'm building. It has a 3D viewport (Three.js, React Three Fiber, the whole stack). You make boxes, cylinders, booleans. Parametric modeling in the browser.
I opened Safari's Web Inspector, hit record on the Timelines tab, then put my hands in my lap. Didn't touch anything. Just watched.
Two seconds later I stopped the recording and saw this:
Every row is solid. Layout, JS, CPU: all firing continuously while I'm not touching anything. The CPU row reads Maximum CPU Usage: 64.2%.
Here's the per-frame breakdown:
| Phase | Time per frame | % |
|---|---|---|
| JavaScript | 1.8ms | 3% |
| Compositing | 10.9ms | 19% |
| GPU/render | 45ms | 78% |
The GPU was redrawing the same scene (same grid, same box, same lighting) sixteen times every second, to nobody. Like a projector playing to an empty theater, burning through bulbs.
The math
I have analytics. Here's the real PostHog dashboard from the last 14 days:
2,750 sessions. Average duration 1 minute 58 seconds. 36% bounce rate. This is a small app. Here's the entire user base:
Nine users. Most visitors are anonymous, poking around.
Let's do the math honestly. Total wasted energy:
Where is sessions per month, is bounce rate, is session duration, is idle fraction, and is excess power draw from continuous rendering.
- sessions/month (extrapolated from 14-day window)
- (bouncers never reach the viewport)
- min (the 1m58s average is dragged down by bounces)
- (CAD is mostly staring and thinking)
- (measured: 64% CPU above idle baseline on an M-series MacBook)
At US average rates: $6.02/year. 16 kg CO. Or 4,724 iPhone charges, if that unit means anything to you.
Six dollars.
The fix took five minutes. One prop change, a few invalidate() calls, delete some dead code. Not hard. Not heroic.
But I think you should fix these anyway, even the six dollar ones. The bug wasn't expensive. The bug was wrong. My app was asking every user's GPU to do work nobody needed, every second they had the tab open. A faucet dripping into the ocean is still a faucet that should be fixed. Fix the bug, because it exists.
And that's before scale. scales linearly with . At 100K daily active users (Figma territory):
31.9 MWh. Enough to power three American homes. 12.8 metric tons of CO. Every web app that renders continuously when idle has a version of this.
We should build things that rest when there's nothing to do.
Plug in your own numbers:
How to catch it
Open your app. Put your hands in your lap. Profile.
Safari: Web Inspector, Timelines, hit the record button. Wait 2-3 seconds. Stop.
Chrome: DevTools, Performance, hit record. Same deal. Stare at it. Stop.
You want to answer one question: is my app doing work right now?
If yes (and you're not touching anything) you have a bug. Maybe a small one. Maybe a six dollar one. But it's there.
The Three.js / R3F fix
If you're using React Three Fiber, the default is frameloop="always". Renders at 60fps no matter what. Static scene? 60fps. User went to get coffee? 60fps. Tab open for 30 seconds with no changes? Believe it or not, 60fps.
One prop:
<Canvas frameloop="demand">Now R3F only renders when something calls invalidate(). Camera moved? Frame. Selection changed? Frame. Nothing happening? Zero frames. Zero GPU. Zero watts.
The catch: telling it when to render
React state changes inside the Canvas automatically trigger frames. If your Zustand store updates, you get a render for free.
But useFrame hooks doing animations need to explicitly ask for the next frame:
// BAD: animation will stutter in demand mode
useFrame(() => {
mesh.rotation.y += 0.01;
});
// GOOD: request the next frame while animating
useFrame(() => {
mesh.rotation.y += 0.01;
invalidate();
});invalidate() comes from useThree:
const { invalidate } = useThree();The pattern: if your useFrame changed something visible, call invalidate(). If it didn't, don't.
Animations that should stop
A hover fade or a camera lerp converges. It should stop requesting frames when it's done:
useFrame(() => {
const delta = targetOpacity - material.opacity;
// Close enough: snap and stop
if (Math.abs(delta) < 0.01) {
material.opacity = targetOpacity;
return; // no invalidate = no more frames
}
// Still animating: keep going
material.opacity += delta * 0.2;
invalidate();
});Without the convergence check, this calls invalidate() forever. Which is just frameloop="always" with extra steps.
What I found in my own code
Three bugs. All mine.
Dead code running every frame. I wrote a useFrame that computed an adaptive grid cell size based on camera distance, stored it in a ref. The Grid component had cellSize={10} hardcoded. The hook ran 16 times per second, computed a value, and threw it away. For months.
Backwards optimization. My post-processing (ambient occlusion, contact shadows) was disabled during camera movement for "performance" and re-enabled at rest. The most expensive effects ran continuously when idle. Off during the only time you're actually looking.
Infinite opacity lerp. Three plane gizmos, each with a hover fade that never converged. material.opacity += (target - current) * 0.2 approaches but never reaches the target. Three eternal animation loops, rendering forever, to move an opacity value by less than one thousandth of a percent.
The checklist
-
Profile idle. Record 2 seconds of doing nothing. If you see continuous frames, keep reading.
-
frameloop="demand". One prop. This alone might fix everything. -
Audit every
useFrame. Search your codebase. Each one needs to answer: does it callinvalidate()? Should it? Does it stop? -
Check
requestAnimationFramecalls. Camera momentum, custom animations: R3F doesn't know about your rAF loops. Callinvalidate()there too. -
Profile idle again. The CPU monitor should be flat. If it's not, something is still ticking.
The result
Before: 64% CPU, 16fps, 45ms GPU per frame. While idle.
After:
0.1% CPU. 1 frame in 4 seconds (the initial render). Zero compositing. Zero layout. The timer events total 0.7ms (just service worker keepalives). Everything else is flat.
Same visual quality. Same responsiveness when interacting. The app just stops working when there's nothing to do. Like it should.
Six dollars a year. Sixteen kilograms of carbon. Not enough to matter. Enough to be worth fixing.
The world gets better in increments this small.
