skip to content

A chart view starts a self-rescheduling requestAnimationFrame loop when it is shown. After the user moves between views several times the page grows steadily slower. What is going on and how do you fix it?

level: middleimportance: must knowfreq 57%

answer

  1. the loop books its own next frame
  2. nothing stops it when the view goes away
  3. one loop per visit, all still running
  4. keep what requestAnimationFrame returned
  5. cancelling a stale handle is harmless

basics

~20 s

Each time the view is shown a new loop starts, and nothing ever stops the old ones, so every frame now runs several loops at once over detached elements. Store the handle requestAnimationFrame returns and call cancelAnimationFrame on teardown.

solid answer

~40 s

A rAF loop has no natural end: the callback books the next frame every time it runs, so it keeps going until something cancels it. Showing the view a second time starts a second independent loop while the first is still alive, and after five visits every frame runs five callbacks, all writing to elements that are no longer in the document. The frame budget fills with dead work, and each closure pins its DOM nodes and data in memory, so it is a leak as well as jank. The fix is to treat the loop as a resource: keep the handle that `requestAnimationFrame` returns, and on teardown call `cancelAnimationFrame(handle)`. Cancelling a handle that has already fired or was never pending is a harmless no-op, so the teardown path can be unconditional.

code

javascript · 24 lines
javascript
let handle = null;
let running = false;

function start() {
  if (running) return;
  running = true;
  const loop = () => {
    if (!running) return;
    // per-frame work goes here
    handle = requestAnimationFrame(loop);
  };
  handle = requestAnimationFrame(loop);
}

function stop() {
  running = false;
  if (handle !== null) {
    cancelAnimationFrame(handle);
    handle = null;
  }
}

start();
stop();

go deeper

for a junior

Remember that requestAnimationFrame returns a handle and that cancelAnimationFrame(handle) is what stops a loop. A self-rescheduling loop runs forever unless you cancel it.

for a middle

Explain why the loops accumulate: each setup starts an independent loop, the handle changes every frame so you must keep the newest one, and the stale closures both burn frame budget and retain detached DOM.

for a senior

Show the diagnosis — slowdown proportional to how often the view was entered, surviving navigation away — and handle the re-entrancy case where an in-flight callback re-registers after the cancel. Treat every registration as a resource with a release path.

for a principal

Set the pattern that makes this class of leak structurally impossible: a single owner for per-frame work, an acquire call that always hands back a disposer, and lifecycle wiring reviewed as part of the component contract rather than left to each author.

## Why the loop never stops on its own `requestAnimationFrame` registers a callback for exactly one frame. The continuous-animation idiom works by re-registering from inside the callback: ```js function loop() { drawChart(); requestAnimationFrame(loop); // books the next frame } requestAnimationFrame(loop); ``` Nothing in that code has a stopping condition. Removing the chart element from the document does not stop it; the callback is held by the browser's frame scheduler, not by the DOM node, and the closure keeps the node alive. Navigating away inside a single-page app does not stop it either, because there is no page unload. The only things that stop it are the callback declining to re-register, `cancelAnimationFrame`, or the page ceasing to exist. ## The accumulation Every time the view is shown, the setup code runs again and starts another independent loop. The loops do not know about each other. After n visits, each frame invokes n callbacks. Symptoms in order of appearance: - the frame budget fills up and the visible animation gets choppy, - CPU and battery use climb even when nothing appears to be moving, - memory grows, because each stale closure retains its element references, its data arrays and whatever else it captured, and none of it can be collected, - errors start appearing from callbacks operating on detached nodes or on state that has since been reset. The give-away when diagnosing is that the slowdown is proportional to how many times the view has been entered, and that it survives leaving the view. ## The fix: own the handle `requestAnimationFrame` returns a positive integer handle identifying that one pending registration. `cancelAnimationFrame(handle)` removes it. Because the loop re-registers every frame, the handle changes every frame, so store the newest one: ```js let handle = null; function start() { const loop = () => { drawChart(); handle = requestAnimationFrame(loop); }; handle = requestAnimationFrame(loop); } function stop() { if (handle !== null) { cancelAnimationFrame(handle); handle = null; } } ``` Every code path that shows the view must have a matching path that tears it down, and the teardown must call `stop()`. Passing a handle that has already fired, or one you already cancelled, does nothing at all — the call is safe to make unconditionally, which means the teardown path needs no cleverness. ## The re-entrancy trap One subtlety: cancelling only removes the *pending* registration. If teardown happens while the callback is mid-flight — for example the callback itself triggers something that tears the view down synchronously — the callback will still finish and its trailing `requestAnimationFrame` will book a fresh frame *after* your cancel ran. A boolean guard closes that hole: ```js let running = false; const loop = () => { if (!running) return; drawChart(); handle = requestAnimationFrame(loop); }; ``` Set `running = true` in start and `false` in stop, before the cancel. ## Idempotent start The mirror-image defect is calling `start()` twice for the same view — from two code paths, or a re-entered setup — which forks the loop into two branches sharing one handle variable, and now the cancel can only reach one of them. Make `start()` a no-op when a loop is already running. ## The design lesson Anything that registers ongoing work with the browser — frame callbacks, timers, event listeners, observers, subscriptions — has the same shape: acquiring it must return a way to release it, and the release must be wired into the same lifecycle that did the acquiring. A rAF loop is easy to overlook precisely because it looks like a one-shot call rather than an ongoing registration.

  • What happens if you call cancelAnimationFrame with a handle whose callback has already run?
    Nothing — the handle no longer identifies a pending registration, so the call is a silent no-op. It is not an error and needs no guard, which is why teardown code can cancel unconditionally rather than trying to track whether a callback is currently outstanding.
  • Does removing the animated element from the document stop the loop?
    No. The scheduler holds the callback, not the element, so the loop keeps running every frame and writes to a detached node. Worse, the closure keeps that node and everything it references reachable, so the removal does not even free the memory. Only cancelling stops it.
  • Is a boolean guard inside the callback enough on its own, without cancelAnimationFrame?
    It stops the loop after one more frame, so it is nearly enough, but it leaves one already-registered callback to run and fire pointlessly. Do both: the flag closes the re-entrancy window where the in-flight callback re-registers after your cancel, and the cancel drops the pending frame immediately.

saying these in an interview costs you the question

  • Assumes the loop stops when the element is removed
  • Never stores the handle requestAnimationFrame returns
  • Thinks cancelAnimationFrame needs the callback function
  • Believes SPA navigation tears down pending frame callbacks
  • Calls start twice and cancels only one branch

context