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

requestAnimationFrame loop versus GSAP timeline Both drivers feed a per-frame interpolation step that writes to the renderer, but GSAP adds a managed timeline and tween registry. Hand-rolled rAF GSAP timeline you call requestAnimationFrame you write easing + interpolation you handle interrupts 0 KB shipped GSAP owns the ticker declarative tweens + eases overwrite + stagger built in ~23 KB core, gzipped
Both paths terminate in the same per-frame write; GSAP trades bundle weight for a managed ticker, overwrite logic, and staggering.

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.