Lazy Load Images with IntersectionObserver for Faster Page Loads

By · Updated

A robust, CLS-safe lazy-loading pattern that pairs native loading="lazy" with an IntersectionObserver enhancer, progressive formats, and a tiny blur-up placeholder.

When to Lazy-Load (and When Not To)

Lazy loading is perfect for below-the-fold assets, galleries, blog images, and long lists. Do not lazy-load the hero/LCP image, critical logos, or above-the-fold media. Those should be eager with fetchpriority="high".

  • Lazy: article images below first viewport, offscreen cards, testimonials.
  • Eager: hero/LCP, brand logo in header, first viewport background art.

Semantic Markup Scaffold (Picture + Blur-Up)

Reserve space with width/height (prevents CLS) and use <picture> for AVIF/WebP fallbacks. The real sources live in data-* attributes until observed.

<figure class="space-y-2">
  <picture class="lazy-picture" data-sizes="(min-width:1024px) 800px, (min-width:640px) 600px, 100vw">
    <!-- Low-res placeholder (tiny blurred image / dominant color) -->
    <img
      class="lazy-img blur-[8px] scale-[1.02] transition-all duration-500 ease-out bg-gray-100 dark:bg-gray-800"
      src="/images/blog/placeholder-20w.webp"
      alt="Team collaborating at a whiteboard"
      width="1200" height="800" loading="lazy" decoding="async" />

    <!-- Data sources activated by JS -->
    <template>
      <source type="image/avif"
              data-srcset="/images/blog/photo-640.avif 640w, /images/blog/photo-800.avif 800w, /images/blog/photo-1200.avif 1200w">
      <source type="image/webp"
              data-srcset="/images/blog/photo-640.webp 640w, /images/blog/photo-800.webp 800w, /images/blog/photo-1200.webp 1200w">
      <img
        data-src="/images/blog/photo-800.jpg"
        data-srcset="/images/blog/photo-640.jpg 640w, /images/blog/photo-800.jpg 800w, /images/blog/photo-1200.jpg 1200w"
        data-sizes="(min-width:1024px) 800px, (min-width:640px) 600px, 100vw"
        alt="Team collaborating at a whiteboard"
        width="1200" height="800" decoding="async" />
    </template>
  </picture>

  <figcaption class="text-sm text-gray-600 dark:text-gray-400">Blur-up placeholder swaps to full-res when observed.</figcaption>

  <!-- Noscript fallback ensures images still render without JS -->
  <noscript>
    <picture>
      <source type="image/avif" srcset="/images/blog/photo-640.avif 640w, /images/blog/photo-800.avif 800w, /images/blog/photo-1200.avif 1200w">
      <source type="image/webp" srcset="/images/blog/photo-640.webp 640w, /images/blog/photo-800.webp 800w, /images/blog/photo-1200.webp 1200w">
      <img src="/images/blog/photo-800.jpg"
           srcset="/images/blog/photo-640.jpg 640w, /images/blog/photo-800.jpg 800w, /images/blog/photo-1200.jpg 1200w"
           sizes="(min-width:1024px) 800px, (min-width:640px) 600px, 100vw"
           alt="Team collaborating at a whiteboard" width="1200" height="800" loading="lazy" decoding="async">
    </picture>
  </noscript>
</figure>

The initial inline <img> acts as a tiny placeholder. JS swaps in the full sources, then removes the blur once decoded.

Tiny IntersectionObserver Loader (Decode, Then Swap)

This script activates sources only when near viewport, decodes before reveal, then gracefully removes blur. It also respects prefers-reduced-data if you wire a toggle.

<script>
// lazy-images.js
(() => {
  const pictures = [...document.querySelectorAll('.lazy-picture')];
  if (!pictures.length || !('IntersectionObserver' in window)) {
    // Graceful fallback: just hydrate all sources immediately
    pictures.forEach(hydratePicture);
    return;
  }

  const io = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      const pic = entry.target;
      hydratePicture(pic);
      io.unobserve(pic);
    }
  }, { rootMargin: '200px 0px', threshold: 0.01 });

  pictures.forEach(p => io.observe(p));

  function hydratePicture(pic){
    // Pull template content (sources + final <img>)
    const tpl = pic.querySelector('template');
    if (!tpl) return;
    const frag = tpl.content.cloneNode(true);

    // Move data-src/srcset/sizes => real attributes
    frag.querySelectorAll('[data-srcset]').forEach(el => {
      el.setAttribute('srcset', el.getAttribute('data-srcset'));
      el.removeAttribute('data-srcset');
    });
    frag.querySelectorAll('[data-sizes]').forEach(el => {
      el.setAttribute('sizes', el.getAttribute('data-sizes'));
      el.removeAttribute('data-sizes');
    });
    const finalImg = frag.querySelector('[data-src]');
    if (!finalImg) return;
    finalImg.setAttribute('src', finalImg.getAttribute('data-src'));
    finalImg.removeAttribute('data-src');

    // Insert new <source> and final <img> before placeholder
    const placeholder = pic.querySelector('.lazy-img');
    pic.insertBefore(frag, placeholder);

    // Decode, then swap visibility
    finalImg.decode?.().catch(() => {}).then(() => {
      // Remove blur+scale on placeholder, then replace it
      placeholder?.classList?.remove('blur-[8px]', 'scale-[1.02]');
      // After transition, remove placeholder
      setTimeout(() => placeholder?.remove(), 250);
    });
  }
})();
</script>

We set a generous rootMargin so images are ready just before they scroll into view.

Tailwind-First Variant (Utility Classes)

If you prefer utilities, the placeholder can be a single class. The rest of the logic stays the same.

<img class="lazy-img w-full h-auto rounded-lg bg-gray-100 dark:bg-gray-800 blur-[8px] scale-[1.02] transition-all duration-500" ...>

Don’t Lazy-Load LCP: Eager + Fetch Priority

Your hero image should ship like this—eager, with explicit width/height, and accurate sizes:

<img
  src="/images/hero-1200.webp"
  srcset="/images/hero-800.webp 800w, /images/hero-1200.webp 1200w, /images/hero-1600.webp 1600w"
  sizes="(min-width:1024px) 960px, 100vw"
  alt="Product hero on gradient background"
  width="1600" height="900"
  loading="eager" fetchpriority="high" decoding="async" />

Build-Time Automation (Node + sharp)

Generate AVIF/WebP/JPEG widths at build time and keep byte budgets tight.

// scripts/make-images.js
import sharp from "sharp"; import fg from "fast-glob"; import { mkdirSync } from "node:fs"; import { dirname } from "node:path";
const sizes = [640, 800, 1200];
for (const file of await fg("src/images/**/*.{jpg,jpeg,png}")) {
  const base = file.replace(/^src\/images\//, "").replace(/\.(jpe?g|png)$/i, "");
  for (const w of sizes) {
    const outDir = `dist/images/${base.split("/").slice(0,-1).join("/")}`;
    mkdirSync(outDir, { recursive: true });
    await sharp(file).resize({ width: w }).avif({ quality: 50 }).toFile(`dist/images/${base}-${w}.avif`);
    await sharp(file).resize({ width: w }).webp({ quality: 78 }).toFile(`dist/images/${base}-${w}.webp`);
    await sharp(file).resize({ width: w }).jpeg({ quality: 74, mozjpeg: true }).toFile(`dist/images/${base}-${w}.jpg`);
  }
}

Performance & Accessibility Guardrails

  • CLS-safe: Always include width/height (or aspect-ratio) to reserve space.
  • Decoding: Use decoding="async" and call decode() before swapping.
  • Formats: Prefer AVIF/WebP with JPEG fallback inside <picture>.
  • Sizes: An accurate sizes string saves bandwidth and improves quality.
  • Lazy threshold: Use rootMargin (e.g., 200–400px) for prefetching just in time.
  • Noscript: Always include a non-JS fallback for crawlers and users with JS disabled.

Common Pitfalls (and Fixes)

  • Images pop in late: Increase rootMargin, ensure CDN caching, reduce file sizes.
  • Blurry on desktop: Add a larger width (e.g., 1600w) and fix sizes string.
  • Layout shifts: Add correct intrinsic width/height or set aspect-ratio.
  • Safari quirks: Keep img as the last child in <picture>; provide MP4/AVIF/WebP in the right order.

QA Checklist

  • ✅ Hero/LCP is not lazy-loaded; has fetchpriority="high".
  • ✅ All lazy images reserve space (no CLS).
  • ✅ AVIF/WebP + JPEG fallback delivered via <picture>.
  • ✅ IntersectionObserver prefetches ~200–400px ahead.
  • ✅ Blur-up transitions out after decode().
  • ✅ Noscript fallback present and valid.

FAQs

Is native loading="lazy" enough?

It’s a great baseline. The IO pattern adds earlier prefetch timing, blur-up control, and decode-aware swaps—smoother UX with minimal JS.

Will lazy loading hurt SEO?

No—content is still in the DOM, and we provide <noscript> fallbacks. Don’t lazy-load critical LCP/hero imagery.

What if IntersectionObserver isn’t supported?

The script hydrates all images immediately (graceful fallback). Support is excellent in modern browsers.

Key Takeaways

  • Don’t lazy-load LCP. Lazy everything else with IO + blur-up.
  • Reserve space with width/height; serve AVIF/WebP with accurate sizes.
  • Decode before reveal; remove blur smoothly; include noscript fallback.
  • Automate derivatives at build time and enforce byte budgets.
Disclaimer: This tutorial is provided for educational and informational purposes only and does not constitute legal, financial, or professional advice. All content is offered “as-is” without warranties of any kind. Readers are solely responsible for implementation and must ensure compliance with applicable laws, accessibility standards, and platform terms. Always apply the information only within authorized, ethical, and legal contexts.

Spot an error or a better angle? Tell me and I’ll update the piece. I’ll credit you by name—or keep it anonymous if you prefer. Accuracy > ego.

Portrait of Mason Goulding

Mason Goulding · Founder, Maelstrom Web Services

Builder of fast, hand-coded static sites with SEO baked in. Stack: Eleventy · Vanilla JS · Netlify · Figma

With 10 years of writing expertise and currently pursuing advanced studies in computer science and mathematics, Mason blends human behavior insights with technical execution. His Master’s research at CSU–Sacramento examined how COVID-19 shaped social interactions in academic spaces — see his thesis on Relational Interactions in Digital Spaces During the COVID-19 Pandemic . He applies his unique background and skills to create successful builds for California SMBs.

Every build follows Google’s E-E-A-T standards: scalable, accessible, and future-proof.