The $6 Bug

I 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:

Safari Web Inspector timeline showing continuous rendering, layout, and 64% CPU while idle

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:

PhaseTime per frame%
JavaScript1.8ms3%
Compositing10.9ms19%
GPU/render45ms78%
64% CPU. 16 frames per second. While doing nothing.

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:

PostHog analytics showing 2.75K sessions, 1m58s average duration, 36% bounce rate

2,750 sessions. Average duration 1 minute 58 seconds. 36% bounce rate. This is a small app. Here's the entire user base:

Supabase dashboard showing 9 total registered users

Nine users. Most visitors are anonymous, poking around.

Let's do the math honestly. Total wasted energy:

Ewaste=S(1b)diPE_{\text{waste}} = S \cdot (1 - b) \cdot d \cdot i \cdot P

Where SS is sessions per month, bb is bounce rate, dd is session duration, ii is idle fraction, and PP is excess power draw from continuous rendering.

  • S=5,880S = 5{,}880 sessions/month (extrapolated from 14-day window)
  • b=0.36b = 0.36 (bouncers never reach the viewport)
  • d5d \approx 5 min (the 1m58s average is dragged down by bounces)
  • i=0.70i = 0.70 (CAD is mostly staring and thinking)
  • P15WP \approx 15\text{W} (measured: 64% CPU above idle baseline on an M-series MacBook)

Ewaste=5,8800.64560h0.7015W=3,300 Wh/month40 kWh/yearE_{\text{waste}} = 5{,}880 \cdot 0.64 \cdot \frac{5}{60}\text{h} \cdot 0.70 \cdot 15\text{W} = 3{,}300 \text{ Wh/month} \approx 40 \text{ kWh/year}

At US average rates: $6.02/year. 16 kg CO2_2. 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. EwasteE_{\text{waste}} scales linearly with SS. At 100K daily active users (Figma territory):

Ewaste100K=100,0001264031,900 kWh/yearE_{\text{waste}}^{\text{100K}} = \frac{100{,}000}{126} \cdot 40 \approx 31{,}900 \text{ kWh/year}

31.9 MWh. Enough to power three American homes. 12.8 metric tons of CO2_2. 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:

Loading calculator...

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

  1. Profile idle. Record 2 seconds of doing nothing. If you see continuous frames, keep reading.

  2. frameloop="demand". One prop. This alone might fix everything.

  3. Audit every useFrame. Search your codebase. Each one needs to answer: does it call invalidate()? Should it? Does it stop?

  4. Check requestAnimationFrame calls. Camera momentum, custom animations: R3F doesn't know about your rAF loops. Call invalidate() there too.

  5. 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:

Safari Web Inspector timeline after fix: completely flat, 0.1% CPU, 1 frame in 4 seconds

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.