# GSAP performance: why animations get janky and how to fix it

> A GSAP performance guide: keep tweens on the compositor, batch ScrollTriggers, sync smooth scroll to one clock, and stop pinned sections jumping.

Published 2026-06-24 by Jake. Canonical: https://gsapvault.com/blog/gsap-performance-guide

---
An animation that looks perfect on your laptop can stutter badly on a mid-range phone. The tween is correct, the easing is right, and yet the motion drops frames and feels cheap.

Jank is rarely the fault of GSAP itself. GSAP is one of the most heavily optimised animation engines available. The stutter almost always comes from what you ask the browser to do on each frame: the properties you animate, the layout work you trigger, and how much runs while the page scrolls.

This guide explains why animations drop frames and gives you a practical checklist for keeping GSAP smooth. The techniques apply whether you write your own tweens or drop in a ready-made effect.

Everything here is written for GSAP 3.14 and uses only APIs that have been stable across GSAP 3 for several releases.

## What "janky" actually means

The browser tries to paint a new frame roughly every 16.7 milliseconds, which is 60 frames per second. On a 120Hz display that budget drops to about 8 milliseconds.

If the work for a frame does not finish inside that budget, the browser misses the deadline. That missed frame is jank: motion that should be continuous instead jumps, hesitates, or tears.

So performance work is really one question: how do you do less work per frame? Everything below is a variation on that theme.

## The compositor versus the main thread

To understand why some properties are cheap and others are expensive, you need a rough mental model of how the browser draws a page. It happens in stages:

1. **Layout** (also called reflow): the browser calculates the size and position of every element.
2. **Paint**: it fills in pixels, colours, text, and shadows.
3. **Composite**: it stacks the painted layers together into the final image.

Layout and paint run on the main thread, the same thread that runs your JavaScript. Compositing can run on a separate thread, often with help from the GPU.

Here is the key insight. If you animate a property that only affects the composite stage, the browser can update the frame without touching layout or paint. That work is cheap and it can happen off the main thread, so it stays smooth even when your JavaScript is busy.

Two properties give you this for free: `transform` and `opacity`.

## Animate transforms and opacity, not layout properties

This is the single most important rule for smooth motion.

```javascript
// Cheap: only affects compositing
gsap.to('.box', {
  x: 300,          // transform: translateX
  scale: 1.2,      // transform: scale
  rotation: 45,    // transform: rotate
  opacity: 0.5,
  duration: 0.6,
});
```

Every property above maps to `transform` or `opacity`. The browser can animate them without recalculating layout, so it stays on the compositor.

Now compare the version that fights the browser:

```javascript
// Expensive: forces layout on almost every frame
gsap.to('.box', {
  left: 300,       // triggers layout
  width: 400,      // triggers layout
  marginTop: 40,   // triggers layout
  duration: 0.6,
});
```

Animating `left`, `top`, `width`, `height`, `margin`, or `padding` forces the browser to recalculate layout on nearly every frame of the tween. On a complex page that recalculation can blow past your frame budget on its own.

The practical translations are worth memorising:

- Moving something: use `x` and `y`, not `left` and `top`.
- Resizing something: use `scale`, not `width` and `height`.
- Hiding something: use `opacity` and `autoAlpha`, not `display` mid-tween.

GSAP's `autoAlpha` is a small quality-of-life win here. It combines `opacity` with `visibility`, so an element fades to zero and then flips to `visibility: hidden`, removing it from interaction without a layout-triggering `display: none`.

## Understand layout thrash

Layout thrash is a specific, common cause of jank, and it is worth naming because it is easy to trip over.

Reading a layout property (like `offsetWidth`, `getBoundingClientRect`, or `scrollTop`) forces the browser to flush any pending layout so it can give you an accurate value. Writing a layout property invalidates the layout again. If you interleave reads and writes in a loop, you force the browser to recalculate layout repeatedly within a single frame.

```javascript
// BAD: read, write, read, write forces layout on every iteration
items.forEach((el) => {
  const height = el.offsetHeight;      // read: forces layout
  el.style.height = height * 2 + 'px'; // write: invalidates layout
});
```

The fix is to batch: read everything first, then write everything.

```javascript
// GOOD: all reads, then all writes
const heights = items.map((el) => el.offsetHeight); // all reads
items.forEach((el, i) => {
  el.style.height = heights[i] * 2 + 'px';          // all writes
});
```

GSAP helps you avoid this in animation code because it batches its own DOM writes through a single internal ticker. The trap usually appears in your own event handlers and setup code, especially when you measure elements to feed values into a tween. Measure once, cache the result, and reuse it.

## Do not create a tween per event

Layout thrash is one way to overload a frame. Allocation is another, and pointer-driven animation is where it usually shows up.

A `mousemove` handler can fire dozens of times a second. If each call creates a fresh tween, you are asking GSAP to build, schedule, and garbage-collect hundreds of short-lived objects per second while it is also trying to render.

```javascript
// BAD: a new tween on every mouse move
element.addEventListener('mousemove', (e) => {
  gsap.to(cursor, { x: e.clientX, y: e.clientY, duration: 0.3 });
});
```

`gsap.quickTo()` fixes this. It builds one tween up front and re-targets it on each call, so the handler allocates nothing.

```javascript
const xTo = gsap.quickTo(cursor, 'x', { duration: 0.3, ease: 'power3' });
const yTo = gsap.quickTo(cursor, 'y', { duration: 0.3, ease: 'power3' });

element.addEventListener('mousemove', (e) => {
  xTo(e.clientX);
  yTo(e.clientY);
});
```

The same rule applies to anything you drive from a high-frequency callback. Changing a timeline's speed should be `tween.timeScale(value)`, not `gsap.to(tween, { timeScale: value })`, which creates a tween to animate a tween.

While you are in that handler, cache your DOM lookups too. A `querySelector` inside a `mousemove` searches the document tree on every event for a result that almost never changes.

## will-change and GPU layers, used sparingly

You can hint to the browser that a property is about to animate, so it promotes the element to its own compositor layer ahead of time:

```css
.card {
  will-change: transform;
}
```

Promoting an element to its own layer means the browser can move it around without repainting its neighbours. That is genuinely useful for the element you are about to animate.

The mistake is applying it to everything. Each layer consumes memory, and on mobile GPUs that memory is limited. Put `will-change: transform` on hundreds of elements and you can make performance worse, not better, sometimes crashing the compositor entirely.

Two rules keep you safe:

- Apply `will-change` only to elements that are actually about to animate, and ideally remove it once the animation is done.
- Do not treat it as a magic "make fast" switch. It is a hint about the near future, not a permanent setting.

For animations you trigger on interaction, add the hint just before and clear it after:

```javascript
const card = document.querySelector('.card');

card.addEventListener('mouseenter', () => {
  card.style.willChange = 'transform';
  gsap.to(card, {
    scale: 1.05,
    duration: 0.3,
    onComplete: () => { card.style.willChange = 'auto'; },
  });
});
```

## force3D and layer promotion in GSAP

GSAP has its own mechanism for pushing an element onto the GPU: the `force3D` setting. When active, GSAP applies a 3D transform (like `translate3d` or `matrix3d`) even for 2D motion, which nudges the browser to promote the element to its own layer.

By default GSAP uses `force3D: "auto"`, which promotes elements while they animate and then reverts them afterward, so you get the benefit during motion without leaving orphaned layers behind. For most work you never need to touch this.

You can force it on for a specific tween when you have measured a benefit:

```javascript
gsap.to('.hero-image', {
  x: 200,
  duration: 1,
  force3D: true, // keep it on the GPU for this tween
});
```

Be deliberate. Forcing 3D on many elements recreates the same over-promotion problem as overusing `will-change`. Reach for it when profiling shows a specific element repainting during a transform-only animation, not as a default.

## ScrollTrigger performance

Scroll is where performance problems get loud, because scroll events fire constantly and any work you attach to them runs at that same rapid pace.

### Never animate inside a scroll handler

If you find yourself writing `window.addEventListener('scroll', ...)` and setting styles inside it, stop. That pattern runs your code on every scroll event, often faster than the browser can paint, and it bypasses GSAP's frame batching entirely.

```javascript
// BAD: work runs on every scroll event, unthrottled
window.addEventListener('scroll', () => {
  const progress = window.scrollY / maxScroll;
  element.style.transform = `translateY(${progress * 100}px)`;
});
```

ScrollTrigger exists precisely to replace this. It listens to scroll once, batches the work into GSAP's ticker, and gives you a clean declarative API.

```javascript
// GOOD: ScrollTrigger batches the work for you
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
  gsap.registerPlugin(ScrollTrigger);

  gsap.to('.element', {
    y: 100,
    ease: 'none',
    scrollTrigger: {
      trigger: '.element',
      start: 'top bottom',
      end: 'bottom top',
      scrub: true,
    },
  });
}
```

### Use scrub for scroll-linked motion

When an animation should follow scroll position, use `scrub` rather than trying to recalculate positions yourself. A numeric value like `scrub: 1` adds a short smoothing catch-up, which both looks better and spreads the work across frames instead of forcing it all into one.

```javascript
scrollTrigger: {
  trigger: '.panel',
  start: 'top top',
  end: '+=1000',
  scrub: 1, // 1 second of smoothing between scroll and animation
}
```

### Batch many triggers into one

A common pattern is revealing dozens of cards as they enter the viewport. Creating a separate ScrollTrigger for every card works, but it means many trigger instances all doing bounds calculations. `ScrollTrigger.batch()` groups them and processes elements together as they cross the threshold.

```javascript
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
  gsap.registerPlugin(ScrollTrigger);

  ScrollTrigger.batch('.card', {
    start: 'top 85%',
    onEnter: (batch) =>
      gsap.from(batch, { y: 40, opacity: 0, stagger: 0.1, overwrite: true }),
  });
}
```

This is the pattern behind grid reveals like the [Stagger Grid Reveal](/effects/stagger-grid-reveal) effect, where many items share one efficient trigger rather than one each.

### Run tickers and loops only when visible

For continuous animations driven by `gsap.ticker`, such as particle fields or canvas loops, there is no reason to keep computing frames while the element is scrolled off screen.

This one catches people out because the browser does some of the work for you and not the rest. A backgrounded tab gets its `requestAnimationFrame` throttled automatically. An element that has merely scrolled past does not: it keeps burning frames at full rate, invisibly, for as long as the reader stays on the page.

Add and remove the ticker function based on visibility.

```javascript
function tick() {
  // per-frame animation logic
}

ScrollTrigger.create({
  trigger: canvas,
  start: 'top bottom',
  end: 'bottom top',
  onEnter: () => gsap.ticker.add(tick),
  onEnterBack: () => gsap.ticker.add(tick),
  onLeave: () => gsap.ticker.remove(tick),
  onLeaveBack: () => gsap.ticker.remove(tick),
});
```

The same logic applies to looping tweens and timelines that are not ticker-driven. An infinite marquee or a rotating badge does not need to run below the fold.

```javascript
ScrollTrigger.create({
  trigger: marquee,
  start: 'top bottom',
  end: 'bottom top',
  onToggle: (self) => (self.isActive ? loop.play() : loop.pause()),
});
```

Canvas-heavy effects like [Canvas Particle Flow](/effects/canvas-particle-flow) and the frame playback in [Scroll Image Sequence](/effects/scroll-image-sequence) benefit most from this, since their per-frame cost is high and pausing it off screen is free performance. The same gating is what stops a continuous loop like [Infinite Marquee](/effects/infinite-marquee) from costing anything while it sits off screen.

## Smooth scroll and ScrollTrigger must share one clock

Smooth scroll libraries are everywhere now, and they are one of the most common sources of scroll jank that is not your animation's fault.

The problem is arithmetic, not effort. A smooth scroller eases the page toward a target position on its own `requestAnimationFrame` loop. ScrollTrigger updates on GSAP's ticker, which is a different `requestAnimationFrame` loop. Two loops, two clocks. The scroller moves the page while ScrollTrigger is still working from the previous frame's position.

You see this as pins that jitter and scrubbed animations that trail the page by a frame. It is worst on anything both pinned and scrubbed, because there the disagreement compounds.

The fix is to stop the scroller running its own loop and drive it from GSAP's ticker instead. Here it is with Lenis, though the shape is the same for any smooth scroller that exposes a `raf` method.

```javascript
gsap.registerPlugin(ScrollTrigger);

// Smooth scroll is the motion, so it is the part reduced motion should skip.
// ScrollTrigger itself stays registered either way.
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
  const lenis = new Lenis({ autoRaf: false });

  // Push every smooth-scroll frame into ScrollTrigger
  lenis.on('scroll', ScrollTrigger.update);

  // One clock: GSAP's ticker drives the scroller
  const lenisTick = (time) => lenis.raf(time * 1000);
  gsap.ticker.add(lenisTick);

  // Do not clamp the delta after a stalled frame, it desyncs the two again
  gsap.ticker.lagSmoothing(0);

  // A refresh restores native scroll position, so bring the scroller with it
  ScrollTrigger.addEventListener('refresh', () => {
    lenis.scrollTo(window.scrollY, { immediate: true, force: true });
  });
}
```

Each line fixes a distinct bug, which is why the block only works as a whole:

- **`autoRaf: false` plus `gsap.ticker.add`** puts both on one loop. This is the part people skip, and skipping it leaves the jitter in place while the code reads as fixed.
- **`lenis.on('scroll', ScrollTrigger.update)`** feeds ScrollTrigger the smooth position rather than letting it poll native scroll.
- **`lagSmoothing(0)`** stops GSAP clamping the time delta after a slow frame or a backgrounded tab. That clamping is useful for ordinary tweens and harmful here, because it puts the two clocks back out of step.
- **the `refresh` listener** resyncs after a resize or a manual `ScrollTrigger.refresh()`. A refresh recalculates pins and restores the native scroll position while the scroller is still easing toward an older target. Without this the page jumps, then fights the reader.

Two more things worth knowing. Call `gsap.ticker.remove(lenisTick)` before destroying the scroller, or the ticker keeps calling `raf` on a dead instance. And smooth scroll libraries generally do not honour `prefers-reduced-motion` themselves, so gate construction on it as above: an eased page is exactly the kind of motion that preference asks you to drop.

If you would rather not wire this up yourself, GSAP's own ScrollSmoother is built on ScrollTrigger and shares its clock by construction. It is free on the CDN alongside every other GSAP plugin.

### Do not use anticipatePin with a smooth scroller

`anticipatePin` engages a pin early, by an amount proportional to scroll velocity. It exists for native scroll, where a single wheel event can jump further than ScrollTrigger can react to and you get a frame of unpinned content.

Once a smooth scroller is feeding every frame into `ScrollTrigger.update`, there is no gap left to cover. The early pin becomes pure error, and the pinned element is yanked upward the moment the section arrives.

```javascript
// BAD when a smooth scroller drives ScrollTrigger
ScrollTrigger.create({ trigger: stage, pin: stage, scrub: 0.55, anticipatePin: 1 });

// GOOD
ScrollTrigger.create({ trigger: stage, pin: stage, scrub: 0.55 });
```

This is not a polish detail. Measured on real pinned pages, the element overshot the scroll by around 120px on slow wheel input and up to 330px on fast input, scaling cleanly with velocity. A lurch of a third of the viewport at the exact moment a reader arrives at a section is what "the scroll feels janky when I scroll fast" usually turns out to be.

You can measure it yourself. Sample `el.getBoundingClientRect().top` and `window.scrollY` every frame. Before the pin engages, `top` should fall by exactly the scroll delta, so anything extra is overshoot.

```javascript
const overshoot = -(scrollY[i] - scrollY[i - 1]) - (top[i] - top[i - 1]);
// 0 on every frame is correct; a positive value means the pin fired early
```

Two things hide the bug. A pinned section that already fills the viewport gives the eye no stationary reference, and scripted scrolling generates no momentum, so `window.scrollTo` in a test will show you nothing. Drive it with real wheel input.

Pinned, scrubbed sections are where all of this converges, which is why effects like [Horizontal Scroll Section](/effects/horizontal-scroll-section) are the ones to check first when a page feels rough.

## Test on real devices, not just your laptop

Your development machine is faster than the phone most of your visitors carry. An animation that never drops a frame on a desktop can struggle on a three-year-old Android.

Two habits catch most problems before users do:

**Throttle the CPU.** Chrome DevTools has a Performance panel with a CPU throttling dropdown. Set it to "4x slowdown" or "6x slowdown" and rerun your animation. Frame drops that were invisible at full speed become obvious.

**Record a performance trace.** Hit record in the Performance panel, run the animation, and stop. Long tasks show up as red-flagged blocks, and purple "Layout" or green "Paint" bars during a supposedly transform-only animation tell you something is triggering work it should not.

If you can, test on an actual mid-range phone over your local network. Nothing substitutes for the real thing, and it reliably surfaces issues that emulation smooths over.

**Fix the causes that only exist on phones.** Two ScrollTrigger settings address problems you will never see on a desktop.

```javascript
// The address bar showing and hiding resizes the viewport, which triggers a
// full refresh and recalculates every trigger mid-scroll.
ScrollTrigger.config({ ignoreMobileResize: true });

// Bigger hammer: moves scrolling onto the JavaScript thread so screen updates
// stay in sync. Fixes pin jitter on iOS, but takes scrolling away from the
// browser's own thread and conflicts with smooth scrollers.
ScrollTrigger.normalizeScroll(true);
```

`ignoreMobileResize` is close to a default worth setting. It skips the refresh on touch devices for vertical resizes under a quarter of the viewport height, which is exactly what the address bar produces. `normalizeScroll` is a real trade-off: reach for it when pinned sections jitter on iOS, and do not combine it with a smooth scroll library that already normalises input.

## prefers-reduced-motion is an accessibility and performance win

Some users set an operating system preference to reduce motion, whether for vestibular disorders, migraines, or simple preference. Respecting [`prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) is not optional, and it is the clearest example of a change that helps accessibility and performance at the same time. The animation you skip is work the device never has to do.

GSAP's `matchMedia` makes this clean. Set up full animation for users who are fine with motion, and a static fallback for those who are not.

```javascript
const mm = gsap.matchMedia();

mm.add('(prefers-reduced-motion: no-preference)', () => {
  gsap.from('.reveal', {
    y: 40,
    opacity: 0,
    stagger: 0.1,
    scrollTrigger: { trigger: '.reveal', start: 'top 85%' },
  });
});

mm.add('(prefers-reduced-motion: reduce)', () => {
  // No motion: just make sure content is visible
  gsap.set('.reveal', { opacity: 1, y: 0 });
});
```

The reduced-motion branch matters. If your only animation is a fade-in from `opacity: 0`, skipping it without a fallback leaves the content invisible. Always set the final visible state explicitly.

## Cleanup and memory

The last source of jank is subtler and shows up over time rather than immediately: leaked animations and triggers. This matters most in single-page apps, where components mount and unmount without a full page reload. Every ScrollTrigger you create but never kill keeps listening. Every ticker function you add but never remove keeps running. They accumulate, and eventually the page feels heavy for no obvious reason.

`gsap.context()` is the tool for this. Wrap your setup in a context, and a single `revert()` call cleans up every tween, timeline, and ScrollTrigger created inside it.

```javascript
const ctx = gsap.context(() => {
  gsap.from('.hero', { y: 50, opacity: 0 });

  gsap.to('.parallax', {
    y: -100,
    scrollTrigger: { trigger: '.parallax', scrub: true },
  });
});

// When the component unmounts or the view changes:
ctx.revert();
```

Your own ticker functions and event listeners are not GSAP's to clean up, but they can still ride along. `gsap.context()` accepts a cleanup function returned from its callback, and runs it on `revert()`. Use named functions so you remove exactly what you added.

```javascript
function handleMouseMove(e) { /* ... */ }
function tick() { /* ... */ }

const ctx = gsap.context(() => {
  gsap.from('.hero', { y: 50, opacity: 0 });

  gsap.ticker.add(tick);
  element.addEventListener('mousemove', handleMouseMove);

  return () => {
    gsap.ticker.remove(tick);
    element.removeEventListener('mousemove', handleMouseMove);
  };
});

ctx.revert(); // reverts the animations and runs your cleanup function
```

In React, `useGSAP` from `@gsap/react` wraps this pattern in a hook and reverts on unmount for you, including under Strict Mode's double-invoked effects. There is a fuller treatment in [GSAP with React: useGSAP, ScrollTrigger and proper cleanup](/blog/gsap-react-usegsap-scrolltrigger).

Long, continuously running animations deserve special care. For infinite marquees and loops, avoid patterns that create a new tween on every cycle, such as calling `gsap.to()` inside an `onRepeat`. A single reusable `gsap.quickTo()` with a `wrap` modifier loops seamlessly without allocating fresh tweens, which keeps memory flat over long sessions.

## The performance checklist

When an animation feels janky, work through these in order:

1. Are you animating `transform` and `opacity` rather than `left`, `top`, `width`, or `height`?
2. Are you reading and writing layout properties in the same loop? Batch reads before writes.
3. Do pointer handlers reuse a `quickTo` instead of creating a tween per event?
4. Is `will-change` applied only to elements that are actively animating, then removed?
5. Are scroll animations driven by ScrollTrigger rather than a raw scroll handler?
6. Are many similar triggers batched with `ScrollTrigger.batch()`?
7. If you use a smooth scroller, is it running on `gsap.ticker` with `lagSmoothing(0)` and a refresh resync?
8. Have you removed `anticipatePin` from every pinned trigger that a smooth scroller drives?
9. Do off-screen tickers and loops get paused or removed on scroll?
10. Have you tested with CPU throttling and, ideally, a real mid-range phone, with `ignoreMobileResize` set?
11. Is there a `prefers-reduced-motion` branch with a visible fallback?
12. Are ScrollTriggers, tickers, and listeners cleaned up when the view unmounts?

Most jank you will meet in practice is one of the first three items. Fixing which properties you animate, where you read layout, and how much you allocate per event resolves the majority of dropped frames before you reach for anything fancier. If the page uses smooth scroll, check items 7 and 8 next, because those two produce the loudest symptoms for the smallest cause.

## Conclusion

Smooth animation is not about a secret setting. It is about respecting the browser's frame budget and doing less work per frame.

Keep motion on `transform` and `opacity` so it stays on the compositor. Avoid forcing layout in loops, and avoid allocating a tween per pointer event. Let ScrollTrigger own scroll work instead of raw handlers. Keep your smooth scroller and ScrollTrigger on one clock, and drop `anticipatePin` when they are. Promote elements to GPU layers deliberately, not everywhere. Test on hardware slower than your own, honour reduced-motion preferences, and clean up what you create.

Do those things and GSAP will hold 60fps on far weaker devices than you might expect. The engine was never the bottleneck; the work you hand it was.

---

*Looking for production-ready GSAP effects with performance and cleanup already handled? Browse the [GSAP Vault effects library](/effects), or start with the [GSAP scroll animations](/categories/gsap-scroll-animations) collection for scroll-linked patterns that already ship with the gating described above.*

## Related articles

- **[GSAP scroll animations: a practical ScrollTrigger guide](/blog/how-to-animate-on-scroll)**: reveals, staggers, scrubbing, pinning, and where each fits.
- **[Horizontal scroll sections with GSAP ScrollTrigger](/blog/horizontal-scroll-gsap)**: the pinned, scrubbed pattern where the smooth-scroll clock and `anticipatePin` rules matter most.
- **[GSAP with React: useGSAP, ScrollTrigger and proper cleanup](/blog/gsap-react-usegsap-scrolltrigger)**: how the cleanup section above translates to components that mount and unmount.
- **[GSAP vs CSS Animations: A Practical Guide](/blog/gsap-vs-css-animations)**: when CSS is enough, when GSAP earns its bundle size, and the performance trade-offs of each.
- **[GSAP vs Anime.js vs Motion Compared](/blog/gsap-vs-animejs-vs-motion)**: bundle sizes, features, and a decision framework across three popular animation libraries.
- **[Apple-Style Scroll Image Sequences](/blog/scroll-image-sequence-tutorial)**: a canvas and ScrollTrigger technique where per-frame cost makes the performance patterns above essential.