Skip to content

Image Lazy Loading — Deferring Offscreen Images Until Needed

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you will learn about Image Lazy Loading. We cover key concepts, practical examples, and best practices to help you master this topic.

Image lazy loading defers offscreen images using native attributes or Intersection Observer, reducing page weight and improving load time.

What You'll Learn

By the end of this tutorial, you'll understand multiple image lazy loading strategies, how to handle responsive images, placeholder techniques, and how to avoid common pitfalls with lazy loaded images.

Why It Matters

Images are the #1 cause of bloated web pages. Proper lazy loading reduces initial page weight by 50-80%, improves LCP (Largest Contentful Paint), and saves bandwidth for users on limited data plans.

Real-World Use

An e-commerce category page shows 48 product thumbnails. The first 8 are visible above the fold and load eagerly. The remaining 40 lazy load as the user scrolls. Initial page weight drops from 4MB to 0.7MB.

Image Lazy Loading Strategies

graph TD
    A[Image Lazy Loading] --> B[Native loading=lazy]
    A --> C[Intersection Observer]
    A --> D[Blur-up placeholder]
    A --> E[LQIP - Low Quality
Image Placeholder] B --> F[Browser handles
loading timing] C --> G[JavaScript controls
when to load] D --> H[Tiny blurred image
as placeholder] E --> I[Small thumbnail
before full image] G --> J[Responsive srcset] G --> K[Lazy load + fade-in] style B fill:#27ae60,color:#fff style C fill:#4a90d9,color:#fff style D fill:#e67e22,color:#fff style E fill:#f39c12,color:#fff

Native Lazy Loading with Placeholder

<!-- Strategy 1: Native lazy loading with blur-up -->
<style>
    .image-wrapper {
        position: relative;
        overflow: hidden;
        background: #f0f0f0;
    }
    .image-wrapper img {
        width: 100%;
        height: auto;
        transition: opacity 0.3s ease;
    }
    .image-wrapper img.lazy {
        opacity: 0;
    }
    .image-wrapper img.loaded {
        opacity: 1;
    }
    .image-wrapper .placeholder {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        filter: blur(20px);
        transform: scale(1.1);
        transition: opacity 0.3s ease;
    }
    .image-wrapper .placeholder.hidden {
        opacity: 0;
    }
</style>

<div class="image-wrapper" style="aspect-ratio: 16/9;">
    <!-- Blurred placeholder -->
    <img src="placeholder-20px.jpg"
         class="placeholder"
         alt=""
         aria-hidden="true"
         width="100%" height="100%">

    <!-- Full image with native lazy loading -->
    <img src="photo-large.jpg"
         srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1200.jpg 1200w"
         sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
         alt="Gallery photo"
         loading="lazy"
         class="lazy"
         width="1200" height="675"
         onload="this.classList.add('loaded'); this.previousElementSibling.classList.add('hidden');">
</div>

Intersection Observer Image Loader

// Advanced image lazy loader with responsive support
class ImageLazyLoader {
    constructor() {
        this.observer = new IntersectionObserver(
            (entries) => this.loadImages(entries),
            { rootMargin: '200px 0px', threshold: 0.01 }
        );

        this.init();
    }

    init() {
        document.querySelectorAll('img[data-src]').forEach(img => {
            this.observer.observe(img);
        });
    }

    loadImages(entries) {
        entries.forEach(entry => {
            if (!entry.isIntersecting) return;

            const img = entry.target;
            this.loadImage(img);
            this.observer.unobserve(img);
        });
    }

    loadImage(img) {
        const src = img.dataset.src;
        const srcset = img.dataset.srcset;

        if (!src && !srcset) return;

        // Set responsive sources
        if (srcset) {
            img.srcset = srcset;
            img.sizes = img.dataset.sizes || '100vw';
        }

        if (src) {
            img.src = src;
        }

        // Handle load event
        img.onload = () => {
            img.classList.add('loaded');
            img.dispatchEvent(new CustomEvent('lazyLoaded'));
        };

        img.onerror = () => {
            img.classList.add('error');
            img.src = img.dataset.fallback || '/images/placeholder-error.jpg';
            console.warn(`Failed to load: ${src || srcset}`);
        };

        // Remove data attributes
        delete img.dataset.src;
        delete img.dataset.srcset;
    }

    refresh() {
        document.querySelectorAll('img[data-src]:not([data-lazy-processed])')
            .forEach(img => {
                img.dataset.lazyProcessed = 'true';
                this.observer.observe(img);
            });
    }
}

// Initialize
const imageLoader = new ImageLazyLoader();

Blur-up and LQIP Techniques

// Blur-up image placeholder generation (server-side)
// Using Sharp to generate placeholders
const sharp = require('sharp');

async function generatePlaceholder(imagePath) {
    // Generate 20px wide blurry placeholder
    const placeholder = await sharp(imagePath)
        .resize(20)
        .blur(5)
        .jpeg({ quality: 30 })
        .toBuffer();

    // Base64 encode for inline use
    const base64 = placeholder.toString('base64');
    const dataUri = `data:image/jpeg;base64,${base64}`;

    // Also generate the responsive sizes
    const sizes = [400, 800, 1200];
    const variants = {};

    for (const size of sizes) {
        const buffer = await sharp(imagePath)
            .resize(size)
            .jpeg({ quality: 80 })
            .toBuffer();

        variants[size] = buffer;
    }

    return {
        placeholder: dataUri,
        width: (await sharp(imagePath).metadata()).width,
        height: (await sharp(imagePath).metadata()).height,
        variants
    };
}

// HTML output with inline blur-up
function renderLazyImage(placeholder, srcset, alt, width, height) {
    return `
    <div class="lazy-image-wrapper" style="aspect-ratio: ${width}/${height}">
        <div class="placeholder"
             style="background-image: url('${placeholder}');
                    background-size: cover;
                    filter: blur(10px);
                    transform: scale(1.1);">
        </div>
        <img src="${srcset.split(',')[0].split(' ')[0]}"
             srcset="${srcset}"
             sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
             alt="${alt}"
             loading="lazy"
             width="${width}" height="${height}"
             onload="this.classList.add('loaded')">
    </div>`;
}

Responsive Lazy Images

<!-- Complete responsive lazy loading pattern -->
<picture>
    <!-- AVIF format (best compression) -->
    <source
        type="image/avif"
        srcset="
            photo-400.avif 400w,
            photo-800.avif 800w,
            photo-1200.avif 1200w
        "
        sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px">

    <!-- WebP format (good compression) -->
    <source
        type="image/webp"
        srcset="
            photo-400.webp 400w,
            photo-800.webp 800w,
            photo-1200.webp 1200w
        "
        sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px">

    <!-- JPEG fallback -->
    <img src="photo-400.jpg"
         srcset="
            photo-400.jpg 400w,
            photo-800.jpg 800w,
            photo-1200.jpg 1200w
         "
         sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
         alt="Responsive lazy loaded image"
         loading="lazy"
         width="1200" height="675"
         decoding="async">
</picture>

Common Mistakes

  1. Lazy loading images without dimensions. Missing width/height causes layout shift when images load. Always specify dimensions or use aspect-ratio CSS.
  2. Using the same placeholder for all images. Generic gray boxes look unprofessional. Generate blur-up placeholders from the actual image.
  3. Not providing fallback for broken lazy images. If a lazy image fails to load, show a placeholder rather than a broken image icon.
  4. Lazy loading too many images at once. If the user scrolls quickly, many images may trigger simultaneously. Consider batch loading with throttling.
  5. Forgetting to set decoding="async". This allows the browser to decode images off the main thread, improving scroll performance.

Practice Questions

  1. What is the difference between native loading=lazy and Intersection Observer for images?
  2. How do blur-up placeholders improve perceived performance?
  3. Why should you set width and height attributes on lazy images?
  4. How does the element work with lazy loading?
  5. What is the LQIP technique and when should you use it?

Challenge: Build an image gallery with three lazy loading strategies: native loading=lazy, Intersection Observer with blur-up placeholders, and a element with AVIF/WebP formats. Benchmark initial page weight, load time, and perceived performance for each.

FAQ

Should I lazy load all images on the page?

No. Above-the-fold images (hero, navigation logos, LCP image) should load eagerly. Lazy loading them delays the initial render pointlessly.

How do I lazy load background images in CSS?

Use Intersection Observer. When the element enters the viewport, add a CSS class that sets the background-image property.

What is the best placeholder strategy?

Blur-up placeholders (20px wide, blurred, base64-encoded inline) provide the best user experience. They show instantly and transition smoothly to the full image.

Does lazy loading affect Google Image Search?

Google can index lazy loaded images if they're in the HTML (data-src attributes). For best SEO, use native loading=lazy which Google explicitly supports.

Can I lazy load images in an email?

No. Most email clients don't support lazy loading. All images in emails load when the email is opened.

Mini Project

Build a responsive image gallery with 50 photos: implement native lazy loading with blur-up placeholders, generate responsive srcsets in 3 sizes and 2 formats (WebP, JPEG), add a custom Intersection Observer with batch loading limits, and create a performance dashboard showing bandwidth saved.

What's Next

Images are handled. Now learn about Iframe Lazy Loading for embedded content and widgets.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro