requestAnimationFrame vs GSAP for Data Transitions
Pick the wrong animation driver and you either ship a 70 KB dependency to tween three numbers, or you hand-roll an interpolator that silently leaks frames and stutters every time fresh data arrives.
Concept overview
Animating a data transition means moving an encoding — a bar height, a circle radius, a color, a path d string — from its old value to a new value over time, sampled once per display refresh. There are two production-grade ways to drive that sampling loop. The first is a hand-rolled requestAnimationFrame (rAF) loop: you own the clock, the easing, and the interpolation. The second is GSAP — a mature tweening engine that owns the loop for you and exposes a declarative timeline API. Both ultimately call requestAnimationFrame under the hood; the difference is how much machinery you write versus import. This guide sits under the high-performance animation and GPU acceleration overview, and it pairs closely with frame-rate stabilization techniques and the D3-native approach in transition and animation sequences.
The contract for either approach is the same: given a start state, an end state, a duration, and an easing function, produce a value for every frame t ∈ [0, 1] and write it to the rendered output. The hard part is never the linear interpolation — it is interrupt handling (new data landing mid-tween), orchestration (staggering hundreds of elements), and frame budget (staying inside 16.67 ms at 60 Hz).
Why interpolation is the easy part
When engineers first reach for an animation library, the stated reason is usually “I don’t want to write the math.” But the math is trivial: a single linear interpolation, a + (b - a) * t, covers every numeric encoding, and color interpolation is three or four of those in a perceptual color space. The difficulty lives entirely in the surrounding control flow. Consider a streaming dashboard where a bar’s value updates every 300 ms. A 400 ms transition will always be interrupted before it finishes. If the second transition starts from the original from value instead of the bar’s current on-screen height, the bar visibly snaps backward before animating forward again — the classic “rubber-banding” artifact. Solving that correctly means tracking the live rendered value, cancelling the in-flight loop, and reseeding. None of that is interpolation; all of it is bookkeeping, and it is exactly the bookkeeping a mature library has already debugged.
The second category of difficulty is fan-out. Animating one value is cheap. Animating ten thousand markers — each with its own start value, its own delay, and its own easing — is an orchestration problem. You must funnel every update through a single loop, sequence the staggers, and keep per-element work small enough that ten thousand of them still fit in a frame. A hand-rolled solution can do all of this, but the code grows quickly, and at that point the question is no longer “which is faster” but “whose code do I want to maintain.”
The third category is the frame budget itself, which neither approach can escape. Both call requestAnimationFrame; both owe the browser a callback that returns in well under 16.67 ms so the compositor can paint. Choosing GSAP does not buy you a faster CPU — it buys you a battle-tested scheduler. If your per-frame work is too heavy, both will drop frames identically, which is why this guide pairs with frame-rate stabilization techniques.
The decision at a glance
Comparison and decision table
| Concern | Hand-rolled rAF | GSAP |
|---|---|---|
| Bundle cost | 0 KB (platform API) | ~23 KB gzipped core; more with plugins |
| Interpolation | You write number/color/path lerps | Built-in tween of any numeric property + plugins |
| Timeline orchestration | Manual offset math per element | gsap.timeline() with labels, position params |
| Stagger across many elements | Hand-compute per-index delay | stagger: { each, from, grid } first-class |
| Interrupt handling | You must cancel + reseed from current value | overwrite: 'auto' resolves conflicts automatically |
| Easing library | Import or write your own curves | 30+ eases, custom CustomEase plugin |
| Frame budget control | Total — you see every callback | Good, but the ticker is a black box |
| Debuggability | Your code, your stack traces | GSDevTools, but third-party internals |
| Best for | 1–3 simple, hot, interrupt-heavy values | Complex multi-track sequences, design-heavy motion |
Use the hand-rolled loop when you are animating a handful of values on a hot interactive path (a brush, a hover, a streaming update) where you need to interrupt and reseed every few hundred milliseconds and cannot justify shipping a library. Reach for GSAP when the animation is the feature — orchestrated reveals, staggered chart builds, chained sequences with overlapping easing — and the orchestration cost of writing it by hand exceeds the bundle cost.
There is also a third option worth naming explicitly: if you are already in a D3 codebase, D3’s own transition() is a fully featured tweening engine with interrupt semantics, named transitions, and per-element interpolators, and it ships nothing extra because you already depend on it. The decision is therefore rarely “rAF or GSAP” in isolation — it is “what is already in my bundle, and what does this specific animation actually need.” The matrix below assumes you have ruled D3 out (you are not in a D3 chart, or you need motion D3 transitions cannot express) and are genuinely choosing between owning the loop and importing a dedicated engine. For the D3-native path, see transition and animation sequences.
A useful heuristic: count the distinct timing relationships in your animation. If everything moves on the same clock with the same easing, a hand-rolled loop is trivial. The moment you need element B to start when element A is 60% done, or a group to stagger by index, or three properties to ease differently and then chain into a fourth, you are building a timeline — and a timeline is precisely what GSAP exists to provide. Re-implementing GSAP’s position parameter and label system by hand is a multi-week project that you will get subtly wrong.
Reference spec
A minimal hand-rolled tween has this shape:
type Easing = (t: number) => number;
interface TweenOptions<T> {
from: T;
to: T;
durationMs: number;
easing?: Easing;
interpolate: (a: T, b: T, t: number) => T; // user-supplied lerp
onFrame: (value: T) => void; // write to renderer
onComplete?: () => void;
}
// Returns a cancel handle so callers can interrupt.
type CancelTween = () => void;
declare function tween<T>(opts: TweenOptions<T>): CancelTween;
The GSAP equivalent collapses to a single declarative call:
import gsap from 'gsap';
// target is any object; GSAP mutates its numeric props in place.
const tween: gsap.core.Tween = gsap.to(target, {
radius: 24,
duration: 0.4,
ease: 'power2.out',
overwrite: 'auto', // cancels conflicting tweens on the same prop
onUpdate: () => render(target),
});
| Symbol | Type | Meaning |
|---|---|---|
Easing |
(t: number) => number |
Maps linear progress [0,1] to eased progress |
interpolate |
(a, b, t) => T |
Produces the in-between value for numbers, colors, or paths |
CancelTween |
() => void |
Stops the rAF loop and frees the closure |
overwrite: 'auto' |
GSAP option | Kills prior tweens touching the same property |
Step-by-step implementation
Build a hand-rolled, interrupt-safe number tween that reseeds from the current value when new data arrives — the case that trips up most teams.
// A delta-time, interrupt-safe number tween.
const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3);
function tweenNumber(
getCurrent: () => number,
to: number,
durationMs: number,
onFrame: (v: number) => void,
): () => void {
let from = getCurrent(); // Step 3: reseed from live value
let start = performance.now();
let rafId = 0;
const step = (now: number): void => {
// PERF: delta from start keeps speed display-rate independent
const t = Math.min((now - start) / durationMs, 1);
const eased = easeOutCubic(t); // Step 4
onFrame(from + (to - from) * eased); // Step 5: write only
if (t < 1) rafId = requestAnimationFrame(step);
};
rafId = requestAnimationFrame(step);
// A11Y: callers should skip the tween entirely when the user
// prefers reduced motion (see accessibility checklist below).
return () => cancelAnimationFrame(rafId); // Step 2: cancel handle
}
// Interrupt-safe usage: cancel old, reseed new.
let cancel: (() => void) | null = null;
function animateRadiusTo(target: { r: number }, next: number): void {
cancel?.(); // Step 2 + 3
cancel = tweenNumber(() => target.r, next, 400, (v) => {
target.r = v;
render(target);
});
}
The structure above is deliberately minimal but production-shaped. The getCurrent callback is what makes it interrupt-safe: every new tween reads the live value at the moment it starts, so a value caught mid-flight continues smoothly from where it is rather than snapping. The cancel handle is the second half of that contract — calling it before starting a replacement guarantees only one loop is ever writing to a given target. Together these two pieces solve the rubber-banding problem that motivated the whole comparison. Easing is pluggable; swapping easeOutCubic for an elastic or back curve is a one-line change, and writing those curves is a few lines of arithmetic each. What you do not get for free is staggering, sequencing, or per-property timelines — the moment you need those, the line count climbs steeply and the maintenance burden tips toward a dedicated engine.
The GSAP version handles steps 2, 3, and 5 for you via overwrite: 'auto', which kills the conflicting tween and lets the new one start from the live property value:
import gsap from 'gsap';
function animateRadiusToGsap(target: { r: number }, next: number): void {
gsap.to(target, {
r: next,
duration: 0.4,
ease: 'power2.out',
overwrite: 'auto', // PERF: no manual cancel/reseed bookkeeping
onUpdate: () => render(target),
});
}
Performance and memory notes
A single tween is O(1) per frame; the cost is proportional to the work inside onFrame. The danger is fan-out: 5,000 elements each owning their own rAF loop means 5,000 callbacks contending every frame, plus 5,000 separate closures, timers, and scheduling decisions. Both approaches must funnel through one loop — GSAP’s ticker does this automatically by registering every active tween with a single shared requestAnimationFrame callback; hand-rolled code must batch all updates into one requestAnimationFrame callback and iterate the dataset there. The naive mistake of calling requestAnimationFrame inside a per-element function turns an O(N) frame into N scheduled callbacks, and the browser will happily run all of them, blowing the budget. Stay under the 16.67 ms frame budget by keeping per-element work cheap, batching writes, and deferring every layout read until after the write phase.
GC pressure comes from per-frame allocations. Never allocate objects, arrays, or strings inside the loop — reuse a scratch object and write primitive values. Path-string interpolation is the worst offender: building a new d attribute string every frame for hundreds of paths churns the heap and triggers minor garbage collections that themselves show up as dropped frames. Precompute interpolators once and emit into a pooled buffer where possible. Color interpolation is a quieter offender — many libraries allocate a new color object per channel per frame; prefer interpolating raw RGB integers and packing them into a single CSS string only at the write step.
Closures captured by an uncancelled rAF loop are a classic leak. A tween that is never cancelled keeps its from/to closure, its target object, and any captured DOM references alive indefinitely, and on a single-page application that navigates between views these accumulate into a steady memory climb. Always invoke the cancel handle on teardown — in React’s useEffect cleanup, Vue’s unmounted hook, or Svelte’s onDestroy. GSAP exposes tween.kill() and gsap.killTweensOf(target) for the same purpose; forgetting them leaks just as readily. Measuring this is the job of diagnosing dropped frames with PerformanceObserver, which surfaces the long frames that per-frame allocation produces.
Framework integration gotchas
Animation drivers and component lifecycles interact badly if you are not careful. The recurring bug is the double-start: a useEffect without a proper dependency array, or one re-run by hot module replacement, starts a second loop while the first is still running, and the two fight over the same property. Always pair loop creation with cleanup in the same effect, and key the effect on the values that should restart the animation. In React, store the target and the cancel handle in useRef so re-renders do not recreate them; only the data change should trigger a new tween. Under Vue and Svelte the equivalent trap is starting a tween in a reactive watcher that fires more often than you expect — debounce the trigger or guard against a tween already in flight.
Accessibility checklist
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| Animation speeds up on 144 Hz monitors | Progress driven by frame count, not time | Use performance.now() deltas divided by duration |
| Values snap or jitter on rapid updates | New tween starts from stale from value |
Reseed from from the live rendered value on each interrupt |
| FPS collapses with many elements | One rAF loop per element | Batch all updates into a single rAF callback |
| Heap grows every second during animation | Per-frame object/string allocation | Reuse scratch objects; avoid building new strings each frame |
| GSAP tweens fight each other | Conflicting tweens on the same property | Set overwrite: 'auto' so the new tween kills the old |
Frequently Asked Questions
Does GSAP use requestAnimationFrame internally?
Yes. GSAP’s ticker is built on a single requestAnimationFrame loop that advances all active tweens each frame. The value GSAP adds is not a different rendering mechanism but the management layer on top: a shared ticker, overwrite resolution, easing library, and timeline sequencing. Choosing GSAP does not bypass the frame budget — you still owe the browser a sub-16.67 ms callback.
When is a hand-rolled loop genuinely better than GSAP?
When you are animating one to three numeric values on a hot interactive path that interrupts frequently — a brush handle, a hover highlight, or a streaming metric — and you cannot justify the bundle cost. In those cases the orchestration features GSAP provides go unused, and a 30-line delta-time tween with a cancel handle is faster to ship, easier to debug, and adds nothing to your download.
How should I handle new data arriving mid-transition?
Cancel the in-flight tween and start a new one that reads its starting value from the current rendered state, not the previous target. With a hand-rolled loop this means calling your cancel handle and reseeding from. With GSAP, set overwrite: 'auto' so the engine kills the conflicting tween and the replacement begins from the live property value. This pairs with the interrupt strategy in transition and animation sequences.
What is the smallest GSAP footprint I can ship?
The GSAP core is roughly 23 KB gzipped before plugins. If you only need basic tweens you can avoid plugins like ScrollTrigger and MotionPath entirely, but you cannot tree-shake below the core. For comparison, a hand-rolled tween plus a couple of easing functions is well under 1 KB — so if bundle size is a hard constraint and your needs are simple, the platform API wins.
Related
- High-Performance Animation & GPU Acceleration — the parent overview for this topic.
- Frame-rate stabilization techniques — keep either driver inside the frame budget.
- Transition and animation sequences — D3’s own transition engine as a third option.
- Debouncing and throttling event listeners — rate-limit the events that trigger transitions.
- Core rendering engines and tradeoffs — what your
onFramewrites into.