Lazy Load Images with IntersectionObserver for Faster Page Loads

By · Updated

A robust, CLS-safe lazy-loading pattern that pairs native loading="lazy" with IntersectionObserver, modern formats, and blur-up placeholders. This guide explains when to lazy-load, how to implement it safely, and why it matters for Core Web Vitals, accessibility, and long-term SEO.

When to Lazy-Load (and When Not To)

Lazy loading is meant for below-the-fold assets (any bulky media you don't see on first load) in areas like galleries, libraries, and long-form blog content. It conserves bandwidth, boosts Core Web Vitals, and improves load speed. That does not mean that everything should be lazy-loaded, however — your LCP image (hero, first viewport graphic) must be eagerly loaded with fetchpriority="high" and loading="eager".

The web.dev lazy loading guide stresses the balance between these levers and how their misuse can delay LCP, lower rankings, and frustrate users. Like with any tool which has the ability to influence page load, think of lazy loading as a scalpel instead of a sledgehammer.

  • Lazy-load: images in long blog posts, product galleries, testimonials, infinite scroll feeds.
  • Eager-load: hero/LCP, header logos, above-the-fold visuals.

For broader strategy, see balancing function and style and content strategy for service sites. Both guides reinforce that performance is in service of conversions.

Semantic Markup Scaffold

Proper lazy loading starts with semantic, CLS-safe markup, which will generally be the case. Before worrying about anything else, set width and height to reserve space — this alone solves most problems with assets. Use <picture> for AVIF/WebP with JPEG fallback.

Keep actual sources in data-* attributes until observed. For more on HTML structure, see HTML/CSS structure best practices.

<figure>
  <picture class="lazy-picture" data-sizes="(min-width:1024px) 800px, 100vw">
    <img
      class="lazy-img blur-[8px] scale-[1.02] transition-all duration-500 ease-out"
      src="/images/blog/placeholder-20w.webp"
      alt="Team collaborating at a whiteboard"
      width="1200" height="800" loading="lazy" decoding="async">

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

This ensures accessibility and SEO are preserved. Crawlers see real content in the DOM. See MDN’s lazy loading docs for browser-level support.

IntersectionObserver Script

The IntersectionObserver API detects when elements approach the viewport without relying on scroll handlers. Our script decodes images before swapping, ensuring smooth transitions. For motion-safe animation, revisit JavaScript scroll reveal effect.

<script>
(() => {
  const pictures = [...document.querySelectorAll('.lazy-picture')];
  if (!pictures.length || !('IntersectionObserver' in window)) {
    pictures.forEach(hydratePicture);
    return;
  }

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

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

  function hydratePicture(pic){
    const tpl = pic.querySelector('template');
    if (!tpl) return;
    const frag = tpl.content.cloneNode(true);

    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) {
      finalImg.setAttribute('src', finalImg.getAttribute('data-src'));
      finalImg.removeAttribute('data-src');
    }

    const placeholder = pic.querySelector('.lazy-img');
    pic.insertBefore(frag, placeholder);

    finalImg.decode?.().catch(() => {}).then(() => {
      placeholder?.classList?.remove('blur-[8px]', 'scale-[1.02]');
      setTimeout(() => placeholder?.remove(), 250);
    });
  }
})();
</script>

Performance & Accessibility Guardrails

  • Always reserve space with width/height or aspect-ratio to prevent CLS.
  • Use decoding="async" and call decode() before swapping to avoid flashes.
  • Prefer AVIF/WebP with JPEG fallback. See W3C image resource guide.
  • Fine-tune rootMargin (200–400px) to prefetch just in time.
  • Always provide a <noscript> fallback for non-JS users and bots.

For more on performance tuning, see how to improve site speed and lazy vs. eager loading.

Troubleshooting Common Issues

  • Images appear late: Increase rootMargin or reduce file sizes. Validate with Chrome Performance panel.
  • Blurry on desktop: Add larger resolutions (e.g., 1600w) and correct sizes.
  • Layout shifts: Ensure width/height attributes are correct.
  • Safari quirks: Place <img> as the last child inside <picture>.

For related dev patterns, see file structure for speed & scale.

QA Checklist Before You Ship

  • Hero/LCP is not lazy-loaded; uses fetchpriority="high".
  • All lazy images reserve intrinsic space (no CLS).
  • AVIF/WebP with JPEG fallback provided in <picture>.
  • IntersectionObserver prefetches 200–400px before viewport.
  • Blur-up transitions out smoothly after decode().
  • Noscript fallback included and valid.

FAQs

Is native loading="lazy" enough?

Native lazy loading is a strong baseline to work from, but the IntersectionObserver pattern adds prefetch timing, blur-up control, and decode-aware swaps — leading to smoother UX. For context, see mobile performance best practices.

Will lazy loading hurt SEO?

Not even a little — content remains in the DOM and noscript fallbacks are provided so search engines can crawl everything. See technical SEO for hand-coded sites.

What if IntersectionObserver isn’t supported?

Then the script hydrates all images immediately, but support is excellent across modern browsers so that is unlikely. The A11y Project checklist recommends graceful fallbacks like this.

Key Takeaways

  • Do not lazy-load LCP. Lazy everything else with IO + blur-up.
  • Always reserve space with width/height or aspect-ratio.
  • Decode images before reveal; remove blur smoothly.
  • Automate format generation at build time to control budgets.
  • Accessibility and SEO are preserved when content exists in HTML.

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.