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.
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:
| Approach | Initial Load | Data Saved |
|---|---|---|
| Eager (all at once) | 3.2 MB | 0 |
| Lazy (visible only) | 400 KB | 2.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
| Value | Behavior |
|---|---|
lazy | Defer loading until near viewport |
eager | Load immediately (default behavior) |
auto | Browser 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
Print Stylesheets
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:
- Use proper semantic HTML
- Include
altattributes - Consider server-side rendering
- 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
- Open Network tab
- Filter by “Img”
- Scroll and watch images load
- 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
| Scenario | Approach |
|---|---|
| Standard images | loading="lazy" |
| Hero/LCP images | No lazy loading + fetchpriority="high" |
| Custom thresholds | Intersection Observer |
| Placeholder needed | LQIP + Intersection Observer |
| Background images | Intersection Observer + CSS |
| Framework apps | Use built-in solutions |
Implementation Checklist
- ✅ Use
loading="lazy"for below-fold images - ✅ Never lazy load LCP images
- ✅ Always include width and height
- ✅ Add
decoding="async"for non-critical images - ✅ Consider placeholders for better UX
- ✅ Test on slow connections
- ✅ Verify with Lighthouse and DevTools
- ✅ 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.