# Every rAF Loop Needs a Stop Condition

**NEVER** · **ID:** `performance-raf-stop-condition` · **Category:** performance
**Source:** [@Ibelick](https://www.ui-skills.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/performance-raf-stop-condition

> NEVER: Ship a `requestAnimationFrame` loop with no terminal condition. `const tick = () => { draw(); requestAnimationFrame(tick); }` never ends: once the value has converged and the user has moved on, it still wakes 60x/sec to compute a delta of zero, burning a core and measurable battery even while the element is visible and idle — the usual cause of an idle tab at 10-15% CPU. Give every loop two exits: stop re-scheduling on convergence (or gesture end / `AbortSignal`), and call `cancelAnimationFrame` on teardown so an unmount cannot orphan the loop. (Distinct from `animations-ibelick-pause-offscreen`, which stops work the user cannot see.)

A requestAnimationFrame loop that re-schedules itself unconditionally never ends, and burns a core for as long as the tab lives

The shape is always the same: `const tick = () => { draw(); requestAnimationFrame(tick); }`. It is correct while the animation is running and wrong for every second after — the value has converged, the spring has settled, the user has moved on, and the loop is still waking up sixty times a second to compute a delta of zero. That is the usual explanation for an idle tab sitting at 10-15% CPU, and on a laptop it is measurable battery drain for no pixels. Every loop needs two exits: a terminal condition (the value converged, the gesture ended, an AbortSignal fired) that stops re-scheduling, and a `cancelAnimationFrame` in the teardown so a component that unmounts mid-animation does not leave an orphan loop holding a reference to a dead tree. This is a different failure from animations-ibelick-pause-offscreen: that rule stops work the user cannot see, this one stops work nobody needs even when the element is in full view.

## Rule snippet

```tsx
const tick = () => {
  if (settled()) return;               // terminal condition
  id = requestAnimationFrame(tick);
};
return () => cancelAnimationFrame(id); // teardown
```

## Bad — do not do this

`performance-raf-stop-condition-bad`

```tsx
import { useEffect, useRef, useState } from 'react';

/**
 * BAD: a rAF loop that re-schedules itself unconditionally.
 * It is correct for the ~40 frames the value takes to settle, and wrong for every
 * frame after that — it keeps waking up 60 times a second to compute a delta of zero.
 * There is no cancelAnimationFrame in the cleanup either, so it also outlives unmount.
 */
export function RafStopConditionBad() {
  const [value, setValue] = useState(0);
  const [frames, setFrames] = useState(0);
  const frameCount = useRef(0);

  useEffect(() => {
    let current = 0;

    const tick = () => {
      current += (100 - current) * 0.08;
      frameCount.current += 1;
      setValue(current);
      setFrames(frameCount.current);

      // Demo guard only, so this page does not drain your battery.
      // The real bug has no guard: it re-schedules forever.
      if (frameCount.current > 1800) return;

      requestAnimationFrame(tick); // no stop condition
    };

    requestAnimationFrame(tick);
    // no cleanup: nothing cancels this loop when the component unmounts
  }, []);

  const settled = Math.abs(100 - value) < 0.5;

  return (
    <div className="space-y-3">
      <div className="h-2 w-full overflow-hidden rounded-full bg-muted">
        <div className="h-full rounded-full bg-primary" style={{ width: `${value}%` }} />
      </div>

      <dl className="flex gap-6 text-sm">
        <div>
          <dt className="text-xs text-muted-foreground">Frames rendered</dt>
          <dd className="font-mono tabular-nums text-foreground">{frames}</dd>
        </div>
        <div>
          <dt className="text-xs text-muted-foreground">Animation state</dt>
          <dd className="font-mono text-foreground">{settled ? 'settled' : 'animating'}</dd>
        </div>
      </dl>

      <p className="text-xs text-destructive">
        The bar reached 100% seconds ago and the counter is still climbing. That is a core held at ~60Hz
        for nothing — the usual explanation for an idle tab sitting at 12% CPU.
      </p>
    </div>
  );
}
```

## Good — do this

`performance-raf-stop-condition-good`

```tsx
import { useEffect, useRef, useState } from 'react';

/**
 * GOOD: two exits. A terminal condition (the value converged) stops the loop from
 * re-scheduling, and cancelAnimationFrame in the effect teardown kills any in-flight
 * frame if the component unmounts mid-animation.
 */
export function RafStopConditionGood() {
  const [value, setValue] = useState(0);
  const [frames, setFrames] = useState(0);
  const frameCount = useRef(0);

  useEffect(() => {
    let current = 0;
    let handle = 0;

    const tick = () => {
      current += (100 - current) * 0.08;
      frameCount.current += 1;
      setValue(current);
      setFrames(frameCount.current);

      // Stop condition: converged, so there is nothing left to draw.
      if (Math.abs(100 - current) < 0.5) {
        setValue(100);
        return;
      }

      handle = requestAnimationFrame(tick);
    };

    handle = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(handle); // no orphan loop after unmount
  }, []);

  const settled = Math.abs(100 - value) < 0.5;

  return (
    <div className="space-y-3">
      <div className="h-2 w-full overflow-hidden rounded-full bg-muted">
        <div className="h-full rounded-full bg-primary" style={{ width: `${value}%` }} />
      </div>

      <dl className="flex gap-6 text-sm">
        <div>
          <dt className="text-xs text-muted-foreground">Frames rendered</dt>
          <dd className="font-mono tabular-nums text-foreground">{frames}</dd>
        </div>
        <div>
          <dt className="text-xs text-muted-foreground">Animation state</dt>
          <dd className="font-mono text-foreground">{settled ? 'stopped' : 'animating'}</dd>
        </div>
      </dl>

      <p className="text-xs text-success">
        The counter freezes the moment the value converges: the loop paid for exactly the frames it needed
        and then gave the CPU back. Cleanup cancels the pending frame, so unmounting mid-animation leaks nothing.
      </p>
    </div>
  );
}
```

## References

- [ibelick — fixing-motion-performance SKILL.md](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-motion-performance/SKILL.md)
- [MDN — requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame)
