Programming Beginner 7 min

How to Lazy-Load Images for Faster Page Loads

Lazy loading defers off-screen images until the user scrolls near them, cutting initial page weight and time-to-interactive. The browser now handles this natively — no library needed for most cases. But there are real caveats: lazy-loading your hero image will hurt your Core Web Vitals score. This guide covers when to apply it, when not to, and how to build the IntersectionObserver fallback for older browsers.

Step-by-step

  1. 1

    Add loading="lazy" to below-fold images

    This is the 80/20 solution. Any image that is not visible on initial load should have loading="lazy". It ships in all modern browsers. No JavaScript, no library — one attribute.

    html
    <!-- Below-the-fold product image: lazy load it -->
    <img
      src="product-photo.jpg"
      alt="Wooden desk with clean setup"
      width="800"
      height="600"
      loading="lazy"
    >
  2. 2

    Never lazy-load above-the-fold images

    Hero images, logo, and anything visible without scrolling must not be lazy-loaded. Lazy loading delays the browser's discovery of the image, directly worsening Largest Contentful Paint (LCP). For your most important above-fold image, go further and add fetchpriority="high".

    html
    <!-- Hero: eager (the default) + high priority -->
    <img
      src="hero.jpg"
      alt="Product hero shot"
      width="1200"
      height="675"
      fetchpriority="high"
    >
    
    <!-- First visible image in a list: eager, no fetchpriority needed -->
    <img
      src="first-card.jpg"
      alt="First card"
      width="400"
      height="300"
    >
  3. 3

    Always set explicit width and height

    Without explicit dimensions the browser cannot reserve space for the image while it loads. The result is Cumulative Layout Shift (CLS) — content jumps when the image arrives. width and height do not force a fixed visual size; CSS still controls that. They just give the browser the aspect ratio it needs to pre-allocate space.

    html
    <!-- Bad: no dimensions = layout shift -->
    <img src="photo.jpg" alt="..." loading="lazy">
    
    <!-- Good: aspect ratio locked, no shift -->
    <img
      src="photo.jpg"
      alt="..."
      width="800"
      height="533"
      loading="lazy"
    >
  4. 4

    Use srcset for responsive images

    Serve the right resolution for each screen size. The browser picks the best fit from the srcset list. Combine this with loading="lazy" — they work independently.

    html
    <img
      src="photo-800.jpg"
      srcset="photo-400.jpg 400w,
              photo-800.jpg 800w,
              photo-1200.jpg 1200w"
      sizes="(max-width: 600px) 100vw,
             (max-width: 900px) 50vw,
             800px"
      alt="Responsive photo"
      width="800"
      height="533"
      loading="lazy"
    >
  5. 5

    Use <picture> for format switching

    Serve WebP to browsers that support it and fall back to JPEG for the rest. <picture> selects the first <source> the browser can handle. loading="lazy" goes on the <img> fallback — not on the <source> elements.

    html
    <picture>
      <source
        type="image/webp"
        srcset="photo-400.webp 400w, photo-800.webp 800w"
        sizes="(max-width: 600px) 100vw, 800px"
      >
      <source
        type="image/jpeg"
        srcset="photo-400.jpg 400w, photo-800.jpg 800w"
        sizes="(max-width: 600px) 100vw, 800px"
      >
      <img
        src="photo-800.jpg"
        alt="Product photo"
        width="800"
        height="533"
        loading="lazy"
      >
    </picture>
  6. 6

    Build the IntersectionObserver fallback

    For browsers that do not support loading="lazy" (primarily Safari before iOS 15.4 and older Chrome), use IntersectionObserver. Store the real URL in data-src and swap it to src when the image enters the viewport.

    javascript
    if ('loading' in HTMLImageElement.prototype) {
      // Native lazy loading supported — nothing to do
    } else {
      // Fallback: IntersectionObserver
      const images = document.querySelectorAll('img[data-src]');
    
      const observer = new IntersectionObserver((entries) => {
        entries.forEach(entry => {
          if (!entry.isIntersecting) return;
          const img = entry.target;
          img.src = img.dataset.src;
          if (img.dataset.srcset) img.srcset = img.dataset.srcset;
          img.removeAttribute('data-src');
          observer.unobserve(img);
        });
      }, { rootMargin: '200px' });  // load 200px before it enters view
    
      images.forEach(img => observer.observe(img));
    }
  7. 7

    Mark fallback images with data-src

    When the fallback is active, the HTML for lazy images stores the URL in data-src instead of src. Use a transparent 1×1 placeholder in src so the element is valid. Modern browsers use the loading attribute and never read data-src.

    html
    <!-- Works with both native lazy loading and the JS fallback -->
    <img
      src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
      data-src="actual-photo.jpg"
      data-srcset="actual-photo-400.jpg 400w, actual-photo-800.jpg 800w"
      alt="Product photo"
      width="800"
      height="533"
      loading="lazy"
    >

Tips & gotchas

  • The first image in a repeated list (cards, products) is often above the fold on large screens — check before adding lazy loading.
  • `loading="lazy"` also works on `<iframe>` elements, not just images. Useful for lazy-loading embedded maps or videos.
  • Use a `rootMargin` of 200–400px in IntersectionObserver so images start loading before they reach the viewport, avoiding a visible pop-in.
  • Run Google Lighthouse or WebPageTest after adding lazy loading. Verify LCP did not get worse — a common mistake is lazy-loading the LCP image.
  • For background images in CSS (not `<img>`), there is no native lazy loading. Use IntersectionObserver to add/remove a class that sets the `background-image`.

Wrapping up

For most projects, adding loading="lazy" to all below-fold images and setting explicit width/height on every image is enough to get a significant performance win. The IntersectionObserver fallback is worth adding if you need to support older browsers, but the native attribute covers the vast majority of real-world traffic. The most important rule: never lazy-load your LCP image.

#Performance #HTML #SEO
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.