Back to guide
Development
Intermediate

7 min read

Lazy Loading Lottie Animations Without Hurting Core Web Vitals

A Lottie animation is just JSON plus a JavaScript player, which means it's invisible to the browser's native image lazy-loading and can silently work against your Core Web Vitals if it's wired up carelessly.

Avoiding layout shift (CLS)

The animation's container has no intrinsic size the way an <img> does until the player mounts and sizes its canvas or SVG. If you don't reserve space up front, the surrounding content jumps the moment the animation initializes.

.lottie-container {
  width: 100%;
  aspect-ratio: 1 / 1; /* reserve space before the player mounts */
}

Don't let it compete with your LCP element

If a large decorative Lottie animation sits above the fold, fetching and parsing its JSON can delay the paint of whatever the browser considers your Largest Contentful Paint element. Two practical fixes: keep hero-area animations small and simple, or load the player library and animation data only after the true LCP content (a heading, a hero image) has already painted.

Only load what's in (or near) the viewport

const observer = new IntersectionObserver(
  async (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) {
        const res = await fetch('/animations/feature-demo.json');
        const data = await res.json();
        lottie.loadAnimation({
          container: entry.target,
          renderer: 'svg',
          animationData: data,
          loop: true,
          autoplay: true,
        });
        observer.unobserve(entry.target);
      }
    }
  },
  { rootMargin: '200px' } // start fetching slightly before it's visible
);

Fetching the JSON only when the container is about to enter the viewport means below-the-fold animations never compete with above-the-fold resources for bandwidth or main-thread time during initial load.

Code-split the player library itself

In a bundler-based app, importing lottie-web or @lottiefiles/dotlottie-web at the top of a component ships it in the main bundle even on pages that never show an animation. A dynamic import() (or, in React, React.lazy / Next.js next/dynamic) keeps the player out of the critical path and loads it alongside the animation data instead.

Keep the JSON itself small

None of the loading strategy above compensates for a bloated animation file. Run it through an optimizer first — a smaller JSON payload means less to fetch, less to parse, and less main-thread work regardless of when you decide to load it.