The ticker: one rAF loop to rule them all
A tween has a tick(dt) method and nothing calling it, and the obvious fix — give every tween its own requestAnimationFrame loop — is exactly the wrong instinct; this lesson builds GSAP's actual answer, a single global ticker that drives every active animation from one delta-time clock, and unpacks why centralizing that clock is the point rather than an implementation detail.
The ticker: one rAF loop to rule them all
The previous lesson built a fifteen-line Tween class with a tick(dt) method that advances its progress and writes a value back onto a target. It works perfectly — if something calls tick with a stream of time deltas. Nothing does. The tween just sits there, fully capable of animating, waiting for a driver it doesn't have.
The obvious fix is to give the tween that driver itself: start a requestAnimationFrame loop inside the tween when it's created, call tick each frame, cancel the loop when it finishes. It would even work, for one tween. The moment you have two tweens running at once, this "obvious" fix turns into the wrong architecture, and the reasons why are exactly what GSAP's real engine — the ticker — is built to avoid.
Why N independent loops go wrong
Say you create two tweens at once, each with its own requestAnimationFrame loop, both meant to run in lockstep — a box moving right while its shadow fades in, say, so they should look perfectly synchronized:
class NaiveTween {
constructor(target, prop, endValue, duration) {
this.target = target;
this.prop = prop;
this.start = target[prop];
this.end = endValue;
this.duration = duration;
this.elapsed = 0;
this.lastTime = performance.now();
this._loop(); // each tween drives *itself*
}
_loop() {
const now = performance.now();
const dt = (now - this.lastTime) / 1000;
this.lastTime = now;
this.elapsed += dt;
const progress = Math.min(this.elapsed / this.duration, 1);
this.target[this.prop] = this.start + (this.end - this.start) * progress;
if (progress < 1) requestAnimationFrame(() => this._loop());
}
}This has three concrete problems, not just a vague "it doesn't scale" hand-wave:
- No shared notion of "now." Each tween computes its own
dtfrom its ownlastTime. Two rAF callbacks scheduled for the "same" frame don't necessarily readperformance.now()at the exact same instant, and any tiny timing skew compounds independently in each tween's own accumulatedelapsed. Animations that are supposed to move as one unit slowly drift apart from each other, frame after frame, because there's no single clock they're both reading from — there are two clocks that happen to start out close. - Redundant per-loop overhead. Every tween registering its own
requestAnimationFramecallback means the browser is invoking N separate callbacks per frame instead of one, each paying its own function-call and closure overhead, when the actual per-frame work — reading a sharednow, updating each tween's state — is identical busywork duplicated N times. - No single place to control everything. Want to pause every animation on the page — say, while a modal opens? With N independent loops there is no "everything," only a pile of separate timers you'd have to track down and cancel individually. Want to globally slow down time for a slow-motion replay, or speed it up? Same problem: there's no one lever, because there's no one thing driving them.
All three problems trace back to the same root cause: time is being computed locally, per tween, instead of centrally, once. The fix isn't to make each loop smarter — it's to have exactly one loop.
The ticker: one rAF loop, a list of subscribers
GSAP's actual design collapses every one of those N loops into a single requestAnimationFrame loop, called the ticker. The ticker doesn't know or care what a tween is — it just keeps a list of things that want to be notified every frame, and each frame it computes one delta time and calls every one of them with it.
class Ticker {
constructor() {
this.listeners = []; // every active tween/timeline subscribes here
this.lastTime = performance.now();
this._running = false;
}
add(fn) {
this.listeners.push(fn);
}
start() {
if (this._running) return;
this._running = true;
requestAnimationFrame(this._tick.bind(this));
}
_tick(now) {
const dt = (now - this.lastTime) / 1000; // seconds since last frame
this.lastTime = now;
// one loop, one `now`, one `dt` — handed to everything at once
for (const fn of this.listeners) fn(dt);
requestAnimationFrame(this._tick.bind(this));
}
}
const ticker = new Ticker();
// A tween no longer drives itself — it just registers with the ticker.
function makeTween(target, prop, endValue, duration) {
const start = target[prop];
let elapsed = 0;
ticker.add((dt) => {
elapsed += dt;
const progress = Math.min(elapsed / duration, 1);
target[prop] = start + (endValue - start) * progress;
});
}
ticker.start();
makeTween(box, "x", 200, 1);
makeTween(shadow, "opacity", 1, 1);Both tweens above read dt from the exact same _tick call, in the exact same frame. There is no skew to accumulate, because there was only ever one measurement of elapsed time to begin with. This is the entire architectural idea: GSAP runs one requestAnimationFrame loop for the whole library, no matter how many tweens or timelines are active, and that single loop is what gsap.ticker actually is under the hood.
The clock is delta time, not frame count
Notice what the ticker hands each listener: dt, a duration in seconds, not a frame number or a fixed increment. This matters for exactly the reason the frame budget lesson spends so much time on: frames get dropped. A long task on the main thread, a slow paint, a background tab briefly regaining focus — any of these can mean the gap between one _tick call and the next is nowhere near the nominal 16.6ms.
A ticker built on a fixed step — "advance every tween by 1/60th of a second every time this callback fires" — would silently run in slow motion the instant a frame got dropped, because it assumes a callback firing is 16.6ms of elapsed time, when really the callback might be firing 33ms or 50ms apart. GSAP's ticker instead measures the actual wall-clock gap between calls and hands that real number to every listener. A dropped frame just means the next dt is bigger — the animation immediately catches up to where it should be in real time, rather than falling permanently behind. This is the same time-based-vs-frame-based distinction the animation module makes about requestAnimationFrame itself: the ticker is the piece of GSAP that receives rAF's timestamp and turns it into that honest dt, once, for everything downstream.
What centralizing the clock unlocks
Once every active tween and timeline is being driven by the same single loop, a handful of controls become possible that would be architecturally awkward — or outright impossible — with N independent loops, because now there's exactly one thing to talk to:
gsap.globalTimeline.timeScale(0.5)— every animation on the page slows to half speed, instantly, by changing one number the ticker's downstream consumers all read. There's no need to reach into individual tweens; they're all fed from the same clock, so scaling that clock's effective rate scales everything at once.- Pausing the whole engine —
gsap.globalTimeline.pause()(or, more bluntly, removing the ticker's own callback from rAF) stops every animation in the document simultaneously, because there's a single point where "keep advancing time" is decided. gsap.ticker.fps(30)— caps how often the ticker actually recomputes and dispatchesdt, useful for deliberately throttling animation work on lower-powered devices, again from one place.gsap.ticker.add(callback)— the ticker's listener list isn't private to GSAP's own tweens. You can hook your own function into the exact same per-frame heartbeat everything else uses — a canvas redraw, a physics step, a debug overlay — and it fires in sync with every tween, because it's subscribing to the identical loop.
None of these are separate features bolted onto the ticker. They all fall directly out of "there's one loop and one clock," the same way the tween's normalized progress made seeking and reversing fall out for free in the previous lesson.
lagSmoothing: what happens when a frame is catastrophically late
There's one failure mode the honest-dt approach makes worse before GSAP corrects it: if the tab is backgrounded for ten seconds and then refocused, or a long synchronous task blocks the main thread for a full second, the next _tick call sees a dt of several thousand milliseconds. Feed that raw into every tween's elapsed, and animations don't "catch up smoothly" — they teleport. A one-second tween just gets dt = 4000ms added to its elapsed time in a single jump, lands past progress = 1 instantly, and every intermediate frame the user would have perceived as motion simply never happened.
GSAP's ticker guards against this with lag smoothing: when it detects a dt far larger than expected (by default, when a frame takes longer than 500ms and the accumulated lag crosses a threshold), it clamps the delta it hands out instead of passing the raw gap through. Rather than letting one enormous dt teleport every animation to its end state, the ticker absorbs that lag internally and feeds out a bounded value, so animations resume from roughly where they were and progress forward normally rather than jumping.
The ticker pauses exactly when rAF does
One more property of the ticker falls out for free rather than needing its own code: because the ticker's loop is itself built on requestAnimationFrame, it inherits rAF's behavior of not firing at all while the tab is hidden — the same mechanism the frame budget lesson points out gives you "pause when hidden" for free. GSAP doesn't need a separate visibility listener to stop animating in a background tab; the ticker simply stops being called, because the browser stops scheduling the callback it's built on. When the tab regains focus, rAF resumes, the ticker's _tick runs again — and it's exactly this reactivation, after a potentially long gap, that lagSmoothing exists to catch.
Where this goes next
The ticker answers who calls tick, and with what dt — the first of the four gaps the previous lesson flagged in the bare interpolation core. The second gap is still wide open: our tween writes a plain number straight onto target[prop], which works for a JavaScript object but says nothing about how a number becomes a CSS transform, an SVG attribute, or a color. That's How GSAP animates anything: the property model and plugins, the next lesson.
Go deeper
- GSAP docs — gsap.ticker — The real ticker API: fps(), lagSmoothing(), add()/remove(), and tick() — the exact surface this lesson's toy Ticker class is modeling.
- GSAP docs — gsap.globalTimeline() — The single parent timeline every tween and timeline lives under, which is what makes one timeScale() or pause() call reach everything at once.
- MDN — requestAnimationFrame — The one-shot, timestamp-carrying primitive the ticker is built directly on top of, including the hidden-tab pausing behavior this lesson relies on.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Name the three concrete problems with giving every tween its own requestAnimationFrame loop, and explain how each one traces back to time being computed locally instead of centrally.
- In the naive per-tween loop, why can two tweens meant to run in perfect sync slowly drift apart from each other over time?
- What does the ticker actually hand to each of its listeners each frame, and why is that a duration rather than a frame count or a fixed increment?
- Explain, in terms of dt, why a dropped frame makes a GSAP animation catch up rather than fall permanently behind.
- Why does centralizing the clock in one ticker make gsap.globalTimeline.timeScale() possible in a way it wouldn't be with N independent rAF loops?
- What problem does lagSmoothing solve, and where does its fix actually live — inside individual tweens, or inside the ticker itself? Why does that placement matter?
- Why does GSAP's ticker automatically stop running when a browser tab is backgrounded, without any code specifically checking tab visibility?