RepDB
← All posts

How to Lazy-Load Exercise Animations in a Fitness App

August 12, 2026 · 10 min read · by Sergei Argutin

A complete still-first pattern: click-to-play, one active clip, reduced motion — with the decode budget measured on a 12-card catalogue grid.

A twelve-card exercise grid pointed at full-resolution 960px clips pulls 37.92 MB. Built from 480px stills, 0.26 MB. Same artwork — one screen asked for the master, the other for what it needed.

That is the whole argument for still-first loading; below is how to implement it. The catch: on a desktop the problem hides. Measured in Chrome over localhost, the 38 MB version held 56 fps and loaded in 83 ms. Neither number lies, and neither measures the thing that degrades.

The still-first pattern

Treat each exercise’s media as two layers:

For an isometric hold — plank, dead hang, static stretch — the poster is the whole story. Branch on the data, not on whether a file exists: RepDB records carry an animation flag, and animation_type says what kind of clip you get. The animation library shows them in a UI.

There is a middle setting — crossfading the start and peak stills in CSS, at ~39 KB against 3.2 MB — covered in how to use exercise images. Below, “demonstration” means the real clip.

The whole pattern, once

Below is the complete thing: a card that renders from a poster, loads the clip on click, and guarantees that only one clip is ever running. Everything after this section is a variation on it.

Incline Dumbbell Curl — still poster
Poster
26 KB · loads with the card
Demonstration
1325 KB · loads only when asked
The card renders from the poster. The clip is requested on click, and nothing on this page requested it for you.

The markup carries both sources, so the JS never has to guess a filename:

<button
  class="exercise-media"
  type="button"
  data-exercise-media
  data-poster-src="/img/exercises/kettlebell-swing-480.webp"
  data-animation-src="/images/animations/kettlebell-swing.webp"
  data-state="idle"
  aria-pressed="false"
>
  <img
    src="/img/exercises/kettlebell-swing-480.webp"
    width="480"
    height="480"
    loading="lazy"
    decoding="async"
    alt="Kettlebell Swing"
  >
  <span class="exercise-media__label">Play demonstration</span>
</button>

One module owns which card is playing. activate() stops whatever was running before it starts anything new, so “one active clip” is a property of the code rather than a rule you hope callers follow:

let active = null;

function mediaOf(card) {
  return card.querySelector('img, video');
}

export function activate(card) {
  if (card === active) return;
  if (active) deactivate(active);

  const media = mediaOf(card);
  if (media instanceof HTMLVideoElement) {
    media.play().catch(() => {});          // autoplay can be refused; not fatal
  } else {
    media.src = card.dataset.animationSrc; // requesting it is enough to play it
  }

  card.dataset.state = 'playing';
  card.setAttribute('aria-pressed', 'true');
  card.querySelector('.exercise-media__label').textContent = 'Stop';
  active = card;
}

export function deactivate(card) {
  const media = mediaOf(card);
  if (media instanceof HTMLVideoElement) {
    media.pause();
  } else {
    media.src = card.dataset.posterSrc;    // back to the still; decoding stops
  }

  card.dataset.state = 'idle';
  card.setAttribute('aria-pressed', 'false');
  card.querySelector('.exercise-media__label').textContent = 'Play demonstration';
  if (active === card) active = null;
}

export function bindCard(card) {
  card.addEventListener('click', () => {
    card.dataset.state === 'playing' ? deactivate(card) : activate(card);
  });
}

Wiring happens once, in one place — see the reduced-motion section below, which is where the cards get bound and the observer gets attached.

Three details that are easy to get wrong:

The poster path above, /img/exercises/kettlebell-swing-480.webp, is a derivative you generate — the bundle ships one size per asset. Which size belongs on which screen is the other half of this; here the poster only has to be small and instant.

Starting playback without a click

Where animation should appear on its own, hand the same activate() to an IntersectionObserver. Because activation is centralised, a fast scroll through a grid still leaves exactly one clip running:

A callback only reports the cards whose visibility changed, so picking the winner out of entries alone gets it wrong the moment the leader is not in the batch. Keep the visible set yourself:

const visible = new Map();   // card -> intersectionRatio

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) visible.set(entry.target, entry.intersectionRatio);
    else visible.delete(entry.target);
  }

  if (visible.size === 0) {
    if (active) deactivate(active);   // nothing on screen: stop decoding
    return;
  }

  // Most-visible card across everything currently on screen, not just this batch.
  const [best] = [...visible].sort((a, b) => b[1] - a[1])[0];
  activate(best);
}, { threshold: [0, 0.25, 0.5, 0.75, 1], rootMargin: '100px 0px' });

Observe only what should ever autoplay — a workout screen’s current step, a single-column feed. A dense catalogue should not: scroll to the bottom of a 20-card grid and you have requested every clip on the page, which for masters is more than 60 MB.

Lazy loading is not the whole answer

loading="lazy" defers a request. It does nothing once ten large animations are visible.

Decoding is its own budget. Measured with the browser’s ImageDecoder across four clips, one 960px frame costs 4.5–5.4 ms against 0.8–1.1 ms at 480px. At the clips’ own 20 fps, one master clip needs 90–108 ms of decode per second of playback — a tenth of a core to keep one card moving. Twelve want more than a whole core.

And you will not catch it in a metric: an animated image decodes outside the page’s animation loop, so requestAnimationFrame reports a calm 56 fps while the clips drop frames, and no browser API exposes their real playback rate. The symptom is a clip that looks slightly wrong next to a video.

Combine lazy loading with:

Respect reduced motion

Reduced motion should switch off automatic playback and leave the user a way in. The common mistake is stripping the animation source, which kills the button too. With the pattern above, it is one condition — bind the cards either way, observe only if motion is welcome:

const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const cards = document.querySelectorAll('[data-exercise-media]');

cards.forEach(bindCard);                       // the explicit control, always

if (!reduceMotion.matches) {
  cards.forEach((card) => observer.observe(card));   // autoplay, only if welcome
}

reduceMotion.addEventListener('change', (event) => {
  if (!event.matches) return;
  observer.disconnect();
  if (active) deactivate(active);              // stop mid-session, too
});

Keep the control labelled and visible. Reduced motion asks you to stop surprising the user, not to withhold the exercise.

Test the page, not the file

One animation always looks cheap on its own. Measure the screens where assets compete: a search page with dozens of cards, a workout plan with ten, a carousel that preloads neighbours.

A twelve-card grid, every tile at 240 CSS px, Chrome on a desktop over localhost — the same grid built three ways:

Grid of 12BytesLoadSustained frame rate
480px stills0.26 MB54 ms56 fps
960px animated WebP37.92 MB83 ms56 fps
MP4, background baked in1.97 MB296 ms28 fps

Three conclusions worth carrying into your own testing:

  1. The animated WebP row is measuring the page, not the clips. The 56 fps is real and it is not evidence of health: at ~100 ms of decode per second per clip, twelve of them over-subscribe a core and the animations drop frames where no metric is watching. The 38 MB is indefensible on mobile data regardless.
  2. Concurrency is the video story. Twenty times fewer bytes, half the frame rate.
  3. Your desktop will sign off on both. Throttle the CPU, throttle the network, or test on the oldest phone you support.

Production checklist

FAQ

Does loading="lazy" lazy-load animated WebP frames?

It defers the request. Once the file is in and visible, every frame still decodes — the attribute has no say over that. Use a still-first pattern when you need real control.

Is replacing src enough to start an animated WebP?

Yes, and that is also the whole API — no pause, no seek, no currentTime. If you need those, use <video> with an MP4.

Should animations autoplay on hover?

As a desktop enhancement, fine. It does not exist on touch, and if the pointer sweeps a grid you can end up with several clips loading at once. Never make it the only way to see the movement.

How many animations can safely be on screen?

There is no universal number, but twelve autoplaying MP4s halved the frame rate of a desktop browser with no other work to do. Treat one as the default and justify anything more with a measurement.

What should the poster show?

The start or peak frame of the same movement, so the swap to motion looks deliberate. Most exercises ship both poses; movements with a single pose ship -main instead, and each record’s images field lists which variants exist — build the path from that rather than assuming a pair.

Method

Sizes measured on the current build: 442 clips in images/animations (960×960, ≤20 fps, median 3.2 MB), 936 stills in images/classic (1024×1024, median 46 KB). Derivatives re-encoded with the settings the bundle ships with — quality 90, method 4, exact=True, disposal 2. Decode cost is measured in Chrome with ImageDecoder, every frame of four clips (34–99 frames), best of three passes; “per second of playback” multiplies the per-frame cost by the clips’ own 20 fps. Grid test: Chrome on a desktop machine, twelve cards at 240 CSS px in a four-column grid, served over localhost, frame rate sampled over three seconds with requestAnimationFrame; the MP4 variant is H.264 crf 23 composited on the card colour. Localhost removes the network, which is precisely why the byte column matters more than the load column.

For the per-screen breakdown of which asset belongs where, see How to Use Exercise Images in a Fitness App. To try the patterns on the real files, the preview viewer runs the production assets in a browser; the full catalogue and licence are on the pricing page.