Cancelling In-Flight Animations on New Data
A live dashboard pushes a data update while the previous transition is still animating, and the bars judder, overshoot, or snap backwards — because two tweens are now writing the same values from different start points every frame.
When updates arrive faster than an animation completes, each new one must supersede the last cleanly. This page is a companion within requestAnimationFrame versus GSAP for data transitions: the rule is that starting a new transition must first cancel the one in flight, and start the new one from the element’s current animated value, not from the stale target the old tween was heading toward. Get either half wrong and you get overlapping animations that fight, or a visible jump as the value teleports to a new start.
Diagnostic checklist
Broken vs fixed
// ❌ BROKEN: each update starts a new loop from the stale target; loops overlap.
let target = 0;
function animateTo(next: number): void {
target = next;
let value = 0; // BUG: resets to 0, and never cancels prior loop
function step(): void {
value += (target - value) * 0.2;
bar.style.height = `${value}px`;
if (Math.abs(target - value) > 0.5) requestAnimationFrame(step); // multiple live loops
}
requestAnimationFrame(step); // no handle kept → cannot cancel
}
// ✅ FIXED: cancel the in-flight loop and continue from the current rendered value.
class BarTransition {
private handle: number | null = null;
private current = 0; // the value actually on screen right now
animateTo(next: number): void {
// Cancel the running loop before starting a new one.
if (this.handle !== null) cancelAnimationFrame(this.handle);
const from: number = this.current; // start from where the bar IS, not a stale target
const start: number = performance.now();
const duration = 300;
const step = (now: number): void => {
const t: number = Math.min(1, (now - start) / duration);
// A11Y: skip the animation entirely under prefers-reduced-motion.
const eased: number = prefersReducedMotion() ? 1 : easeOutCubic(t);
this.current = from + (next - from) * eased;
this.bar.style.height = `${this.current}px`;
if (t < 1) this.handle = requestAnimationFrame(step);
else this.handle = null; // PERF: release the handle when settled
};
this.handle = requestAnimationFrame(step);
}
constructor(private readonly bar: HTMLElement) {}
}
function easeOutCubic(t: number): number { return 1 - Math.pow(1 - t, 3); }
declare function prefersReducedMotion(): boolean;
Two invariants make this correct. First, one loop per target: keeping the rAF handle and calling cancelAnimationFrame before scheduling the next step guarantees exactly one live loop writes the element, so no two tweens fight over its value. Second, start from the current rendered value: capturing this.current — the value actually painted this instant — as the new tween’s origin means the motion continues from where the eye last saw it, with no jump. The broken version violates both: it resets value to zero and leaks a new loop on every call, so several loops race and each restart snaps the bar back to the floor.
Step-by-step fix
// GSAP equivalent: overwrite auto-kills conflicting tweens on the same target.
import gsap from "gsap";
function gsapRetarget(bar: HTMLElement, next: number): void {
// overwrite: "auto" kills any in-flight tween of the same property on this target.
gsap.to(bar, { height: next, duration: 0.3, ease: "power2.out", overwrite: "auto" });
}
Verification
// After a rapid retarget, exactly one rAF loop should be live.
let liveLoops = 0;
const realRAF = requestAnimationFrame;
(globalThis as unknown as { requestAnimationFrame: typeof requestAnimationFrame }).requestAnimationFrame =
(cb: FrameRequestCallback): number => { liveLoops++; return realRAF(() => { liveLoops--; cb(performance.now()); }); };
const t = new BarTransition(bar);
t.animateTo(100);
t.animateTo(40); // must cancel the first before the second schedules
t.animateTo(80);
console.assert(liveLoops <= 1, `expected at most 1 live loop, saw ${liveLoops}`);
In DevTools, fire several updates in quick succession and record a Performance trace: the animated property should trace one continuous curve to the final value with no reversals, and the frame timeline should show a single rAF callback per frame rather than several. Overlapping callbacks or a sawtooth value curve mean cancellation is missing.
Debounce, retarget, or queue — choosing the response to rapid updates
Cancel-and-replace is the right default, but it is one of three sensible responses to updates that outpace the animation, and they suit different data. Retargeting — cancel the old tween, continue from the current value toward the new target — keeps the visualization live and is best for a continuously changing metric where every update matters and the user wants to see the latest. Debouncing the updates, so a burst collapses into a single transition after a short quiet period, suits noisy streams where intermediate values are not meaningful and you would rather animate once to a settled state; it pairs naturally with the rate-limiting in choosing debounce versus throttle for chart interactions. Queuing, where each transition runs to completion before the next begins, is almost always wrong for data because it introduces growing lag under sustained updates and shows values the data has already moved past.
The subtle failure mode shared by all three is starting from the wrong value. Retargeting must read the current rendered value; debouncing must animate from the value on screen when the quiet period ends, not from the first update in the burst; and a queue must at least skip to the newest target rather than replaying every intermediate one. Whenever a transition begins, its origin is the pixel the user is looking at right now, and its destination is the freshest data — anything else shows a jump or animates toward a value that is already stale.
Edge cases & gotchas
- Cancelling a completed loop. Calling
cancelAnimationFrameon a handle whose callback already ran is harmless, but reusing a stale handle to cancel a different loop is not; null the handle on completion so it cannot alias a later one. - GSAP overwrite modes.
overwrite: "auto"kills only conflicting properties on the same target, whiletruekills all tweens of the target; pick per property or an unrelated tween on the same element dies unexpectedly. - Reading current value from the DOM. Parsing a computed style to get the current value forces a layout read; track the animated value in JavaScript state instead and keep the DOM read out of the hot path.
- Multiple properties per element. Cancelling one property’s loop must not stop another’s; key handles by property, or a colour tween dies when a height update arrives.
- Unmount during animation. A tween still running after its element is removed writes to a detached node and leaks; cancel on teardown as well as on new data.
- Reduced motion and rapid updates. Even with animation disabled, applying every update instantly can strobe; under
prefers-reduced-motionconsider snapping to the latest value on a throttled interval rather than every frame.
Frequently Asked Questions
Why do my bars jump backwards when data updates quickly?
Because a new transition starts from a stale origin — often zero or the previous target — instead of the value currently on screen, and because the old animation was never cancelled, so two loops write conflicting values each frame. Cancel the in-flight animation and start the replacement from the current rendered value, and the motion continues smoothly toward the new target with no reversal.
How do I cancel a GSAP tween when new data arrives?
Either call kill() on the stored tween before creating the next, or pass overwrite: "auto" to the new gsap.to, which automatically kills conflicting tweens of the same property on the same target. GSAP retargets from the current animated value for you, so the replacement continues smoothly. Prefer overwrite: "auto" over true so unrelated tweens on the element survive.
Should I cancel, debounce, or queue rapid updates?
Cancel and retarget when every update matters and you want the latest value shown live. Debounce when the stream is noisy and intermediate values are not meaningful, so a burst animates once to a settled state. Avoid queuing for data, because running each transition to completion introduces growing lag and shows values the data has already superseded.
Related
- requestAnimationFrame vs GSAP for Data Transitions — the parent guide to driving data transitions.
- Debounce vs Throttle for Chart Interactions — rate-limiting the updates that trigger transitions.
- Interrupting D3 Transitions on Rapid Updates — the same cancellation problem in D3’s transition system.