Performance 18 min read

Lazy Loading Implementation Strategies

Master image lazy loading for web performance. Learn native loading, Intersection Observer, placeholder techniques, and framework implementations for optimal user experience.

By ImageGuide Team · Published January 19, 2026 · Updated January 19, 2026
lazy loadingperformanceintersection observerweb performanceloading strategies

Lazy loading defers image loading until users need them. This simple technique can dramatically reduce initial page weight and improve Core Web Vitals. Here’s everything you need to implement it effectively.

Why Lazy Load Images?

Consider a typical blog post with 15 images:

ApproachInitial LoadData Saved
Eager (all at once)3.2 MB0
Lazy (visible only)400 KB2.8 MB (87%)

Users who don’t scroll see only what they need. Users who do scroll load images just in time.

Performance Benefits

  • Faster initial page load: Less data to download upfront
  • Improved LCP: Resources prioritized for above-fold content
  • Reduced data usage: Mobile users on limited plans benefit
  • Lower server costs: Fewer resources served per pageview
  • Better Core Web Vitals: Particularly LCP and TTI

Native Lazy Loading

The simplest approach: add loading="lazy" to your images.

Basic Implementation

<img src="photo.jpg" loading="lazy" alt="Description" width="800" height="600">

Browser Support

Native lazy loading works in all modern browsers (97%+ global support):

  • Chrome 77+
  • Firefox 75+
  • Safari 15.4+
  • Edge 79+

How It Works

Browsers start loading images when they’re approximately 1250-2500px from the viewport (varies by browser and connection speed).

┌────────────────────────────┐
│       Viewport            │ ← Visible images load immediately
├────────────────────────────┤
│                           │
│    ~1250-2500px buffer    │ ← Images start loading here
│                           │
├────────────────────────────┤
│                           │
│    Not loaded yet         │ ← Waits until user scrolls
│                           │
└────────────────────────────┘

Loading Attribute Values

ValueBehavior
lazyDefer loading until near viewport
eagerLoad immediately (default behavior)
autoBrowser decides (same as omitting)

Best Practices

Do lazy load:

  • Below-the-fold images
  • Image galleries
  • Long article content
  • Product listings (except first row)

Don’t lazy load:

  • Hero/LCP images
  • Above-the-fold content
  • Images critical to initial experience
  • Background images for layout
<!-- Hero image: load immediately -->
<img src="hero.jpg" fetchpriority="high" alt="Hero" width="1920" height="1080">

<!-- Content images: lazy load -->
<img src="content-1.jpg" loading="lazy" alt="Content" width="800" height="600">
<img src="content-2.jpg" loading="lazy" alt="Content" width="800" height="600">

Intersection Observer API

For more control than native lazy loading, use the Intersection Observer API.

Basic Implementation

// Create observer
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      img.removeAttribute('data-src');
      observer.unobserve(img);
    }
  });
});

// Observe all lazy images
document.querySelectorAll('img[data-src]').forEach(img => {
  observer.observe(img);
});
<!-- Image with data-src instead of src -->
<img data-src="photo.jpg" alt="Description" width="800" height="600">

Configuration Options

const observer = new IntersectionObserver(callback, {
  // Element to use as viewport (null = browser viewport)
  root: null,

  // Margin around root (load images before they enter viewport)
  rootMargin: '200px 0px',

  // Percentage of element visible to trigger (0-1)
  threshold: 0
});

Loading with srcset

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;

      // Set srcset first, then src
      if (img.dataset.srcset) {
        img.srcset = img.dataset.srcset;
      }
      img.src = img.dataset.src;

      observer.unobserve(img);
    }
  });
});
<img
  data-src="photo-800.jpg"
  data-srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1200.jpg 1200w"
  sizes="(min-width: 768px) 50vw, 100vw"
  alt="Photo"
  width="800"
  height="600"
>

Handling Loading States

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      const wrapper = img.parentElement;

      // Add loading class
      wrapper.classList.add('loading');

      img.onload = () => {
        wrapper.classList.remove('loading');
        wrapper.classList.add('loaded');
      };

      img.onerror = () => {
        wrapper.classList.remove('loading');
        wrapper.classList.add('error');
      };

      img.src = img.dataset.src;
      observer.unobserve(img);
    }
  });
});

Placeholder Techniques

Placeholders improve perceived performance while images load.

Solid Color Placeholder

Simplest approach—use the dominant color:

<div class="image-wrapper" style="background-color: #3b82f6;">
  <img src="photo.jpg" loading="lazy" alt="Blue sky" width="800" height="600">
</div>
.image-wrapper {
  aspect-ratio: 4 / 3;
}

.image-wrapper img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 0.3s;
}

.image-wrapper img[src] {
  opacity: 1;
}

Low-Quality Image Placeholder (LQIP)

Load a tiny blurred version first:

<div class="image-wrapper">
  <!-- Tiny placeholder (inline or separate request) -->
  <img
    src="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
    class="placeholder"
    alt=""
    aria-hidden="true"
  >
  <!-- Full image -->
  <img
    src="photo.jpg"
    class="full-image"
    loading="lazy"
    alt="Description"
    width="800"
    height="600"
  >
</div>
.image-wrapper {
  position: relative;
  aspect-ratio: 4 / 3;
  overflow: hidden;
}

.placeholder {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: blur(20px);
  transform: scale(1.1); /* Hide blur edges */
}

.full-image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 0.5s ease-out;
}

.full-image.loaded {
  opacity: 1;
}
document.querySelectorAll('.full-image').forEach(img => {
  if (img.complete) {
    img.classList.add('loaded');
  } else {
    img.addEventListener('load', () => img.classList.add('loaded'));
  }
});

BlurHash Placeholder

BlurHash encodes images into tiny strings that decode to blurred previews:

import { decode } from 'blurhash';

// BlurHash string (typically stored in your CMS/database)
const blurhash = 'LEHV6nWB2yk8pyo0adR*.7kCMdnj';

// Decode to pixels
const pixels = decode(blurhash, 32, 32);

// Render to canvas
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(32, 32);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);

// Use as placeholder background
element.style.backgroundImage = `url(${canvas.toDataURL()})`;

Skeleton Loading

CSS-only animated placeholder:

.skeleton {
  aspect-ratio: 4 / 3;
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

@keyframes shimmer {
  0% { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}
<div class="image-wrapper">
  <div class="skeleton"></div>
  <img src="photo.jpg" loading="lazy" alt="Description" onload="this.previousElementSibling.remove()">
</div>

Framework Implementations

React

Using native lazy loading:

function LazyImage({ src, alt, width, height }) {
  return (
    <img
      src={src}
      alt={alt}
      width={width}
      height={height}
      loading="lazy"
      decoding="async"
    />
  );
}

With Intersection Observer:

import { useRef, useEffect, useState } from 'react';

function LazyImage({ src, alt, placeholder }) {
  const [isLoaded, setIsLoaded] = useState(false);
  const [isInView, setIsInView] = useState(false);
  const imgRef = useRef();

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsInView(true);
          observer.disconnect();
        }
      },
      { rootMargin: '200px' }
    );

    if (imgRef.current) {
      observer.observe(imgRef.current);
    }

    return () => observer.disconnect();
  }, []);

  return (
    <div className="image-wrapper">
      {placeholder && !isLoaded && (
        <img src={placeholder} alt="" className="placeholder" aria-hidden="true" />
      )}
      <img
        ref={imgRef}
        src={isInView ? src : undefined}
        alt={alt}
        className={`full-image ${isLoaded ? 'loaded' : ''}`}
        onLoad={() => setIsLoaded(true)}
      />
    </div>
  );
}

Vue 3

<template>
  <div class="image-wrapper">
    <img
      v-if="placeholder && !isLoaded"
      :src="placeholder"
      alt=""
      class="placeholder"
      aria-hidden="true"
    />
    <img
      ref="imgRef"
      :src="isInView ? src : undefined"
      :alt="alt"
      :class="['full-image', { loaded: isLoaded }]"
      @load="isLoaded = true"
    />
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const props = defineProps(['src', 'alt', 'placeholder']);

const imgRef = ref(null);
const isInView = ref(false);
const isLoaded = ref(false);

let observer;

onMounted(() => {
  observer = new IntersectionObserver(
    ([entry]) => {
      if (entry.isIntersecting) {
        isInView.value = true;
        observer.disconnect();
      }
    },
    { rootMargin: '200px' }
  );

  if (imgRef.value) {
    observer.observe(imgRef.value);
  }
});

onUnmounted(() => {
  observer?.disconnect();
});
</script>

Next.js

Next.js Image component handles lazy loading automatically:

import Image from 'next/image';

// Lazy loaded by default
<Image
  src="/photo.jpg"
  alt="Description"
  width={800}
  height={600}
/>

// Eager loading for LCP images
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1920}
  height={1080}
  priority
/>

// With blur placeholder
<Image
  src="/photo.jpg"
  alt="Description"
  width={800}
  height={600}
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,..."
/>

Astro

---
import { Image } from 'astro:assets';
import photoSrc from '../images/photo.jpg';
---

<!-- Lazy loaded by default -->
<Image
  src={photoSrc}
  alt="Description"
  width={800}
  height={600}
/>

<!-- Eager loading -->
<Image
  src={photoSrc}
  alt="Hero"
  loading="eager"
/>

Background Image Lazy Loading

CSS background images don’t support loading="lazy". Use Intersection Observer:

const lazyBackgrounds = document.querySelectorAll('.lazy-bg');

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('loaded');
      observer.unobserve(entry.target);
    }
  });
});

lazyBackgrounds.forEach(el => observer.observe(el));
.lazy-bg {
  background-color: #f0f0f0; /* Placeholder */
}

.lazy-bg.loaded {
  background-image: url('background.jpg');
}

Or use data attributes:

<div class="lazy-bg" data-bg="url('background.jpg')"></div>
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const el = entry.target;
      el.style.backgroundImage = el.dataset.bg;
      observer.unobserve(el);
    }
  });
});

Handling Edge Cases

Lazy-loaded images might not appear when printing:

@media print {
  img[loading="lazy"] {
    /* Force display */
    content-visibility: visible;
  }
}

Or use JavaScript to load all images before print:

window.addEventListener('beforeprint', () => {
  document.querySelectorAll('img[data-src]').forEach(img => {
    img.src = img.dataset.src;
  });
});

JavaScript Disabled

Provide fallback with <noscript>:

<img data-src="photo.jpg" alt="Description" class="lazy">
<noscript>
  <img src="photo.jpg" alt="Description">
</noscript>

SEO Considerations

Search engines handle native lazy loading well, but for JavaScript-based solutions:

  1. Use proper semantic HTML
  2. Include alt attributes
  3. Consider server-side rendering
  4. Test with Google’s URL Inspection tool

Accessibility

<!-- Ensure alt text is always present -->
<img
  data-src="photo.jpg"
  alt="Descriptive alt text"
  loading="lazy"
  width="800"
  height="600"
>

<!-- For decorative images -->
<img
  data-src="decoration.jpg"
  alt=""
  role="presentation"
  loading="lazy"
>

Performance Optimization

Preconnect to Image Origins

<head>
  <link rel="preconnect" href="https://cdn.example.com">
  <link rel="dns-prefetch" href="https://cdn.example.com">
</head>

Avoid Layout Shifts

Always include dimensions:

<img
  src="photo.jpg"
  loading="lazy"
  alt="Description"
  width="800"
  height="600"
>

Or use aspect-ratio:

.lazy-image {
  aspect-ratio: 4 / 3;
  width: 100%;
}

Combine with Responsive Images

<img
  src="photo-800.jpg"
  srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1200.jpg 1200w"
  sizes="(min-width: 768px) 50vw, 100vw"
  loading="lazy"
  decoding="async"
  alt="Description"
  width="800"
  height="600"
>

Monitor Performance

// Track lazy load timing
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      const startTime = performance.now();

      img.onload = () => {
        const loadTime = performance.now() - startTime;
        console.log(`Image loaded in ${loadTime}ms:`, img.src);

        // Send to analytics
        analytics.track('lazy_image_load', {
          src: img.src,
          loadTime,
          viewport: `${window.innerWidth}x${window.innerHeight}`
        });
      };

      img.src = img.dataset.src;
      observer.unobserve(img);
    }
  });
});

Common Mistakes

Mistake 1: Lazy Loading LCP Images

<!-- WRONG: Delays the largest contentful paint -->
<img src="hero.jpg" loading="lazy" alt="Hero">

<!-- CORRECT: Load hero immediately -->
<img src="hero.jpg" fetchpriority="high" alt="Hero">

Mistake 2: No Dimensions

<!-- WRONG: Causes layout shift -->
<img src="photo.jpg" loading="lazy" alt="Photo">

<!-- CORRECT: Include dimensions -->
<img src="photo.jpg" loading="lazy" width="800" height="600" alt="Photo">

Mistake 3: Lazy Loading Everything

<!-- WRONG: Even above-fold images are lazy -->
<img src="logo.jpg" loading="lazy" alt="Logo">
<img src="hero.jpg" loading="lazy" alt="Hero">
<img src="product.jpg" loading="lazy" alt="Product">

<!-- CORRECT: Only lazy load below-fold -->
<img src="logo.jpg" alt="Logo">
<img src="hero.jpg" fetchpriority="high" alt="Hero">
<img src="product.jpg" loading="lazy" alt="Product">

Mistake 4: Forgetting decoding=“async”

<!-- Good but could be better -->
<img src="photo.jpg" loading="lazy" alt="Photo">

<!-- BETTER: Also async decode -->
<img src="photo.jpg" loading="lazy" decoding="async" alt="Photo">

Mistake 5: Complex JavaScript When Native Works

// WRONG: Reinventing native lazy loading
const observer = new IntersectionObserver((entries) => {
  // Complex logic...
});
<!-- CORRECT: Just use native -->
<img src="photo.jpg" loading="lazy" alt="Photo">

Use Intersection Observer only when you need features native loading doesn’t provide (custom thresholds, placeholders, callbacks).

Testing Lazy Loading

Chrome DevTools

  1. Open Network tab
  2. Filter by “Img”
  3. Scroll and watch images load
  4. Check timing waterfall

Lighthouse

Look for:

  • “Defer offscreen images” opportunity
  • “Properly size images” diagnostic

Throttle Network

Test on slow connections:

DevTools → Network → Throttle → Slow 3G

Performance Monitoring

// Log when images enter viewport vs when they load
const entries = [];

new PerformanceObserver((list) => {
  list.getEntries().forEach(entry => {
    if (entry.initiatorType === 'img') {
      entries.push({
        src: entry.name,
        startTime: entry.startTime,
        duration: entry.duration
      });
    }
  });
}).observe({ entryTypes: ['resource'] });

Summary

Quick Reference

ScenarioApproach
Standard imagesloading="lazy"
Hero/LCP imagesNo lazy loading + fetchpriority="high"
Custom thresholdsIntersection Observer
Placeholder neededLQIP + Intersection Observer
Background imagesIntersection Observer + CSS
Framework appsUse built-in solutions

Implementation Checklist

  1. ✅ Use loading="lazy" for below-fold images
  2. ✅ Never lazy load LCP images
  3. ✅ Always include width and height
  4. ✅ Add decoding="async" for non-critical images
  5. ✅ Consider placeholders for better UX
  6. ✅ Test on slow connections
  7. ✅ Verify with Lighthouse and DevTools
  8. ✅ Monitor real-world performance

Lazy loading is one of the simplest yet most effective performance optimizations. Start with native loading="lazy", add placeholders for polish, and only reach for JavaScript when you need advanced features.

Related Resources

Platform Guides

Ready to optimize your images?

Sirv automatically optimizes, resizes, and converts your images. Try it free.

Start Free Trial