Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 8.1Intermediate ~25 min · shot 1 of 6

The requestAnimationFrame Loop Done Right

In one line

A hand-written animation loop needs four habits: add up elapsed time instead of counting frames, share one loop between all animations, clamp the gap after a tab switch, and stop when nothing is left to move.

0/6 · 0%

Think of it like this

A band plays to one conductor. If every musician brings their own metronome, the tempos drift, and a musician who starts two metronomes plays everything twice as fast. One conductor gives one beat, and when the music ends the conductor puts the baton down. A shared animation loop is that conductor.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

requestAnimationFrame
A browser function that runs your callback once, just before the next paint. Call it again inside the callback to keep a loop going.
timestamp
The number the browser passes to the callback: milliseconds since the page started, taken at the start of the frame. Every callback in one frame gets the same value.
delta time (dt)
The time since the previous frame in milliseconds, about 16.7 at 60 Hz. Moving by speed × dt keeps the motion the same on every screen.
ticker
One shared loop that calls a list of functions every frame and ends itself when the list is empty.
clamp
Force a number into a range: Math.min(Math.max(n, low), high). Here it stops a huge or negative dt.
subscriber
A function added to the ticker. It runs every frame until it returns false or is removed.

Step by step

01Run the shared loop and read the console

"One loop for two slides" starts two slides with one ticker. The first console line says subscribers: 2 | loop running: true: two functions share one loop. There are no tracks on the timeline, because tracks list CSS and Web Animations API animations, not loops written by hand.

02Pause and step the loop

Press Pause, then Slide again, then Step a few times. Each step runs one frame, and the orange dot moves by the same small amount: dt is the same every frame. The timeline's clock also drives requestAnimationFrame, so your loop is as steppable as a CSS animation.

03Watch the loop end itself

Let both slides finish. The orange dot is done at 300 ms and the blue one at 500 ms, and one frame after the blue one the console prints subscribers: 0 | loop running: false. Nobody is left, so the ticker stopped requesting frames.

04Meet the four traps

"Four ways a loop goes wrong" prints its results as lines. Two loops started for one element, the clamp of an 8-second gap, a timestamp just before last, and a loop for an element that was removed.

Try it yourself

  1. 1

    Clamp too hard and watch time slow down

    In "One loop for two slides", change createTicker() to createTicker({ maxDelta: 5 }). Every frame can now add at most 5 ms, and a 60 Hz frame is about 16.7 ms, so time runs at about a third of its speed. In a real run the orange slide, which should take 300 ms, finished after about 1000 ms and the blue one, 500 ms long, after about 1700 ms. A clamp is a safety net: set it well above one frame.

  2. 2

    Count the loops

    In "Four ways a loop goes wrong", press Start both again. The orange dot (two loops adding to one position, 4 px a frame) arrives after about 400 ms, and even overshoots to 102 px, because each loop checks x < 100 before the other has added its 2. The blue dot (one guarded loop, 2 px a frame) arrives after about 830 ms (50 frames). Double speed is the sign of a loop started twice.

Code & diagrams

One loop for two slides

createTicker() is the new code in loop.js. Each slide adds up dt and returns false when it is done. Press Pause, then Slide again, then Step.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

subscribers: 2 | loop running: true
orange done
blue done
subscribers: 0 | loop running: false
Four ways a loop goes wrong

The two loops in the first case both do x += 2 on one shared number, so the box moves twice as fast. The guarded version stops the old loop before it starts a new one.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

naive, two clicks -> loops running: 2
guarded, two clicks -> subscribers: 1
normal frame, 16 ms gap -> 16
back from another tab, 8 s gap -> 50
timestamp just before last -> 0
first frame with last = 0 -> 50
after the element left the page -> subscribers: 0

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget to return false

In "One loop for two slides", delete the line return false; // done: leave the loop.

terminal
$ Run the example and read the console
── what you'll see ──
subscribers: 2 | loop running: true
orange done
orange done
subscribers: 2 | loop running: true
orange done
…
Captured from a real run. The lines keep coming, one set per frame, and the last line ("subscribers: 0") never appears.

Myth vs fact

Myth

requestAnimationFrame runs 60 times a second.

Fact

It runs once per screen refresh: 30, 60, 90, 120 or 240 times a second depending on the device, not at all in a hidden tab, and sometimes at a lower rate to save power. That is why you add up dt instead of counting calls.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The ticker copies its set ([...subs]) before the loop, so a subscriber that adds or removes subscribers cannot change the set while it is being walked.

  • ▸

    A hand-written loop ignores prefers-reduced-motion: ask motionAllowed() (Phase 7) before you start it. Do reads and writes in separate passes. Reading getBoundingClientRect() after a style write forces layout (Topic 6.4). One ticker can run a read phase, then a write phase, for every subscriber.

Remember this

  1. 1

    The problem this solves: the tween from Phase 1 starts its own requestAnimationFrame loop for each animation. That is fine for one loading bar. On a page with ten moving things, a double click, or an element that is removed mid-animation, you get loops that fight over one value, loops that run for nothing, and no single place to stop, slow or measure them.

  2. 2

    Recap, then deeper. The JavaScript course (Topic 11.7) showed that requestAnimationFrame(callback) runs your function just before the next paint and passes a timestamp. Deeper: that timestamp is the start of the current frame, the same for every callback in it. Use it, not Date.now().

  3. 3

    Delta time. The gap between two frames is dt = now - last. Moving something by speed × dt each frame, or adding dt to an elapsed-time counter, makes it last the same time at 30, 60 and 120 Hz (Topic 1.3). Phase 1's tween instead measures now - start from one fixed start. Adding up dt is the better habit when speed can change while running (springs in Phase 9, slow motion, a draggable) because each frame only needs the last step, not the whole history.

  4. 4

    One loop for many animations. createTicker() (below) keeps a set of subscribers and runs one requestAnimationFrame loop for all of them. The browser would run ten callbacks in one frame anyway, so the gain is not speed. It is control: one place to clamp time, to pause everything, to count what is running, and to guarantee that the loop ends when the last subscriber leaves.

  5. 5

    Clamp the gap. A background tab does not get animation frames. When the learner comes back, the first timestamp is seconds later, and dt would be seconds. Move something by speed × 8000 ms and it teleports; a physics model explodes (Topic 9.2). The ticker clamps dt to 0..50 ms, which also fixes two small traps: the very first frame (where last must be set when the loop starts, or dt is "now minus 0") and a timestamp that lands a hair before last, which would make dt negative.

  6. 6

    The trade-off of clamping. Adding up clamped dt means the animation *pauses* while the tab is hidden and continues where it was. Phase 1's absolute tween *jumps to the end* when you return. A loading bar tied to real time should jump, a bouncing ball should pause: decide on purpose.

  7. 7

    Stop when done, stop when gone. A subscriber returns false when it finishes, and the ticker lets the loop end when the set is empty. A loop that never ends drains a phone's battery. Keep the function that add() returns and call it when the component leaves, or check el.isConnected inside the callback. And guard against double starts: before starting a loop for an element, stop the one it already has.

Explain it without notes

01

Why add up dt instead of counting frames or using one fixed start time?

02

What does clamping dt fix, and what does it change about the animation?

03

What does one shared loop give you that ten separate loops do not?

Practice

01

Add pause() and resume() to createTicker. While paused, no subscriber runs and no frames are requested, and when resumed the first dt must not include the pause.

02

Write slideTo(el, x) on the ticker so that calling it again while the element is moving cancels the first move and continues from the current position.

Done when you can

  • You can explain why a loop adds up dt instead of counting frames

  • You can say what the clamp fixes and what it changes

  • You can start, stop and guard a loop so double clicks and removed elements leave nothing running

  • You know when to use a loop and when to hand the work to CSS or the Web Animations API