How to Lazy Load Images and Components in JS

Learn how to lazy load images and components in JavaScript. Covers blur-up placeholders, responsive image lazy loading, progressive JPEG loading, skeleton screens, conditional component loading, and a complete lazy media framework.

JavaScriptadvanced
16 min read

Lazy loading images and components requires more than just delaying requests. This guide covers advanced patterns like blur-up placeholders, responsive srcset lazy loading, skeleton screens, and conditional component hydration.

For general lazy loading patterns and data loading, see Lazy Loading in JavaScript: Complete Tutorial.

Blur-Up Image Pattern

javascriptjavascript
class BlurUpImage {
  constructor(img, options = {}) {
    this.img = img;
    this.fullSrc = img.dataset.src;
    this.thumbSrc = img.dataset.thumb;
    this.transitionDuration = options.transition || 400;
  }
 
  init() {
    // Set up blur filter on the container
    const wrapper = document.createElement("div");
    wrapper.className = "blur-up-wrapper";
    wrapper.style.cssText = `
      position: relative;
      overflow: hidden;
      display: inline-block;
    `;
 
    this.img.parentNode.insertBefore(wrapper, this.img);
    wrapper.appendChild(this.img);
 
    // Load tiny thumbnail first (1-2KB)
    if (this.thumbSrc) {
      this.img.src = this.thumbSrc;
      this.img.style.filter = "blur(20px)";
      this.img.style.transform = "scale(1.1)";
      this.img.style.transition = `filter ${this.transitionDuration}ms, transform ${this.transitionDuration}ms`;
    }
 
    return this;
  }
 
  load() {
    return new Promise((resolve, reject) => {
      const fullImg = new Image();
 
      fullImg.onload = () => {
        this.img.src = this.fullSrc;
        this.img.style.filter = "blur(0)";
        this.img.style.transform = "scale(1)";
 
        setTimeout(() => {
          this.img.style.transition = "";
        }, this.transitionDuration);
 
        resolve(this.img);
      };
 
      fullImg.onerror = reject;
      fullImg.src = this.fullSrc;
    });
  }
}
 
// Automatic blur-up with IntersectionObserver
function setupBlurUpImages() {
  const images = document.querySelectorAll("img[data-thumb]");
  const blurImages = [];
 
  images.forEach((img) => {
    const blurUp = new BlurUpImage(img).init();
    blurImages.push(blurUp);
  });
 
  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const idx = [...images].indexOf(entry.target);
          if (idx >= 0) {
            blurImages[idx].load();
            observer.unobserve(entry.target);
          }
        }
      });
    },
    { rootMargin: "200px" }
  );
 
  images.forEach((img) => observer.observe(img));
}

Responsive Lazy Loading with Srcset

javascriptjavascript
class ResponsiveLazyImage {
  constructor(img) {
    this.img = img;
    this.sizes = img.dataset.sizes;
    this.srcset = img.dataset.srcset;
    this.fallbackSrc = img.dataset.src;
  }
 
  load() {
    if (this.srcset) {
      this.img.srcset = this.srcset;
    }
    if (this.sizes) {
      this.img.sizes = this.sizes;
    }
    if (this.fallbackSrc) {
      this.img.src = this.fallbackSrc;
    }
 
    this.img.classList.add("loaded");
  }
 
  static observeAll(selector = "img[data-srcset]", rootMargin = "250px") {
    const images = document.querySelectorAll(selector);
 
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            new ResponsiveLazyImage(entry.target).load();
            observer.unobserve(entry.target);
          }
        });
      },
      { rootMargin }
    );
 
    images.forEach((img) => observer.observe(img));
    return observer;
  }
}
 
// HTML usage:
// <img
//   data-src="photo-800.jpg"
//   data-srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1200.jpg 1200w"
//   data-sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
//   width="800" height="600"
//   alt="Landscape photo"
// >
 
ResponsiveLazyImage.observeAll();

Skeleton Screen Component

javascriptjavascript
class SkeletonScreen {
  static create(config) {
    const skeleton = document.createElement("div");
    skeleton.className = "skeleton-screen";
    skeleton.setAttribute("aria-hidden", "true");
    skeleton.setAttribute("role", "presentation");
 
    config.forEach((block) => {
      const el = document.createElement("div");
      el.className = `skeleton-${block.type}`;
 
      switch (block.type) {
        case "text":
          el.style.cssText = `
            height: 16px;
            width: ${block.width || "100%"};
            background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
            background-size: 200% 100%;
            animation: shimmer 1.5s infinite;
            border-radius: 4px;
            margin-bottom: ${block.margin || "8px"};
          `;
          break;
 
        case "circle":
          el.style.cssText = `
            width: ${block.size || "48px"};
            height: ${block.size || "48px"};
            border-radius: 50%;
            background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
            background-size: 200% 100%;
            animation: shimmer 1.5s infinite;
          `;
          break;
 
        case "rect":
          el.style.cssText = `
            width: ${block.width || "100%"};
            height: ${block.height || "200px"};
            background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
            background-size: 200% 100%;
            animation: shimmer 1.5s infinite;
            border-radius: ${block.radius || "8px"};
            margin-bottom: ${block.margin || "12px"};
          `;
          break;
      }
 
      skeleton.appendChild(el);
    });
 
    return skeleton;
  }
 
  static card() {
    return SkeletonScreen.create([
      { type: "rect", height: "180px" },
      { type: "text", width: "70%" },
      { type: "text", width: "100%" },
      { type: "text", width: "40%" },
    ]);
  }
 
  static profile() {
    return SkeletonScreen.create([
      { type: "circle", size: "64px" },
      { type: "text", width: "150px" },
      { type: "text", width: "100px" },
    ]);
  }
}
 
// Inject shimmer animation
const style = document.createElement("style");
style.textContent = `
  @keyframes shimmer {
    0% { background-position: -200% 0; }
    100% { background-position: 200% 0; }
  }
`;
document.head.appendChild(style);

Conditional Component Loading

javascriptjavascript
class ConditionalLoader {
  constructor() {
    this.conditions = new Map();
  }
 
  when(condition, loader) {
    this.conditions.set(condition, loader);
    return this;
  }
 
  async evaluate() {
    const results = {};
 
    for (const [condition, loader] of this.conditions) {
      const shouldLoad = typeof condition === "function"
        ? await condition()
        : condition;
 
      if (shouldLoad) {
        try {
          results[loader.name || "module"] = await loader();
        } catch (error) {
          console.error("Conditional load failed:", error);
        }
      }
    }
 
    return results;
  }
}
 
// Load components based on conditions
const loader = new ConditionalLoader();
 
loader
  .when(
    () => window.innerWidth >= 1024,
    () => import("./components/DesktopSidebar")
  )
  .when(
    () => window.innerWidth < 768,
    () => import("./components/MobileNav")
  )
  .when(
    () => document.querySelector("[data-chart]") !== null,
    () => import("./components/ChartWidget")
  )
  .when(
    () => navigator.geolocation !== undefined,
    () => import("./components/LocationWidget")
  );
 
loader.evaluate().then((modules) => {
  Object.values(modules).forEach((mod) => {
    const Component = mod.default || mod;
    if (typeof Component.init === "function") {
      Component.init();
    }
  });
});

Lazy Media Framework

javascriptjavascript
class LazyMediaFramework {
  constructor(options = {}) {
    this.rootMargin = options.rootMargin || "300px";
    this.imageObserver = null;
    this.componentObserver = null;
    this.loadedCount = 0;
    this.totalTracked = 0;
    this.onProgress = options.onProgress || null;
 
    this.init();
  }
 
  init() {
    this.imageObserver = new IntersectionObserver(
      (entries) => this.handleImages(entries),
      { rootMargin: this.rootMargin }
    );
 
    this.componentObserver = new IntersectionObserver(
      (entries) => this.handleComponents(entries),
      { rootMargin: "100px" }
    );
  }
 
  handleImages(entries) {
    entries.forEach((entry) => {
      if (!entry.isIntersecting) return;
 
      const img = entry.target;
      const strategy = img.dataset.strategy || "swap";
 
      switch (strategy) {
        case "blur":
          new BlurUpImage(img).init().load();
          break;
 
        case "responsive":
          new ResponsiveLazyImage(img).load();
          break;
 
        case "swap":
        default:
          if (img.dataset.srcset) img.srcset = img.dataset.srcset;
          if (img.dataset.sizes) img.sizes = img.dataset.sizes;
          if (img.dataset.src) img.src = img.dataset.src;
          img.classList.add("loaded");
      }
 
      this.imageObserver.unobserve(img);
      this.reportProgress();
    });
  }
 
  handleComponents(entries) {
    entries.forEach((entry) => {
      if (!entry.isIntersecting) return;
 
      const container = entry.target;
      const modulePath = container.dataset.component;
 
      if (modulePath) {
        const skeleton = SkeletonScreen.card();
        container.appendChild(skeleton);
 
        import(/* @vite-ignore */ modulePath)
          .then((mod) => {
            container.innerHTML = "";
            const Component = mod.default || mod;
            if (typeof Component === "function") {
              Component(container);
            }
          })
          .catch(() => {
            container.innerHTML = "<p>Failed to load</p>";
          });
      }
 
      this.componentObserver.unobserve(container);
      this.reportProgress();
    });
  }
 
  observeImages(selector = "img[data-src]") {
    const images = document.querySelectorAll(selector);
    this.totalTracked += images.length;
    images.forEach((img) => this.imageObserver.observe(img));
    return this;
  }
 
  observeComponents(selector = "[data-component]") {
    const components = document.querySelectorAll(selector);
    this.totalTracked += components.length;
    components.forEach((el) => this.componentObserver.observe(el));
    return this;
  }
 
  reportProgress() {
    this.loadedCount++;
    if (this.onProgress) {
      this.onProgress({
        loaded: this.loadedCount,
        total: this.totalTracked,
        percent: Math.round((this.loadedCount / this.totalTracked) * 100),
      });
    }
  }
 
  destroy() {
    this.imageObserver.disconnect();
    this.componentObserver.disconnect();
  }
}
 
// Usage
const framework = new LazyMediaFramework({
  rootMargin: "400px",
  onProgress: ({ loaded, total, percent }) => {
    console.log(`Loaded ${loaded}/${total} (${percent}%)`);
  },
});
 
framework.observeImages().observeComponents();
TechniqueVisual QualityLoad SpeedImplementation
Simple src swapJarring pop-inFastestEasiest
Blur-up thumbnailSmooth transitionMediumModerate
Skeleton screenConsistent layoutN/A (placeholder)Moderate
LQIP (Low Quality)Progressive revealMediumComplex
Dominant colorMinimal flashFastModerate
Rune AI

Rune AI

Key Insights

  • Blur-up thumbnails provide immediate visual context: Load a tiny 1-2KB thumbnail with CSS blur, then transition to the full image for a smooth reveal effect
  • Always prevent layout shift with explicit dimensions: Set width/height attributes or CSS aspect-ratio on image containers to reserve space before lazy images load
  • Responsive srcset with lazy loading serves optimal sizes: Combine data-srcset with IntersectionObserver to load the right image size for the viewport without downloading oversized assets
  • Skeleton screens maintain perceived structure: Use shimmer animation placeholders that match the final component layout to prevent jarring content reflows
  • Conditional loading eliminates unused code: Check viewport size, DOM state, and device capabilities before importing components that may never render
RunePowered by Rune AI

Frequently Asked Questions

What is the performance difference between blur-up and skeleton screens?

Blur-up works best for image-heavy layouts where users expect visual content. It loads a tiny (1-2KB) thumbnail immediately, providing context. Skeleton screens work better for structured content (cards, lists, profiles) where the layout shape matters more than pixel content. Both prevent layout shift by reserving space.

How do I prevent layout shift (CLS) with lazy loaded images?

lways set explicit `width` and `height` attributes on image elements, or use CSS `aspect-ratio`. This reserves space before the image loads. For responsive images, use the `aspect-ratio` CSS property matching the image dimensions. The container wrapper approach in the blur-up pattern naturally prevents CLS.

Should I lazy load images on mobile differently than desktop?

Yes. Use smaller `rootMargin` on mobile (100-200px vs 300-400px for desktop) since users scroll faster and bandwidth is more limited. Consider using `navigator.connection.effectiveType` to reduce image quality on slow connections. Load fewer above-the-fold images eagerly on mobile since the viewport is smaller.

How do I handle lazy loading in a virtual scroll or recycled list?

Virtual scroll libraries handle their own lazy loading since items are created and destroyed as the user scrolls. Do not use IntersectionObserver for items inside a virtual scroll. Instead, let the virtual scroll library manage which items are rendered and load images immediately when items enter the DOM.

When should I use conditional component loading?

Use conditional component loading when components are only needed for specific device types (desktop sidebar, mobile navigation), specific user roles (admin panels), or when specific DOM elements exist on the page. This avoids loading unnecessary JavaScript that will never execute, reducing total bundle weight.

Conclusion

Lazy loading images and components with the right visual strategy transforms perceived performance. Blur-up placeholders provide context during loading. Skeleton screens maintain layout structure. Responsive lazy loading with srcset serves optimal image sizes. Conditional component loading eliminates unused JavaScript. For general lazy loading patterns, see Lazy Loading in JavaScript: Complete Tutorial. For scroll-based infinite loading, see Implementing Infinite Scroll with JS Observers.