Slow-loading images can destroy your website's performance and SEO rankings. In this beginner-friendly guide, you'll learn how to implement lazy loading to improve your Core Web Vitals—specifically LCP (Largest Contentful Paint) and CLS (Cumulative Layout Shift). Let's make your site lightning fast!
What Are Core Web Vitals?
Core Web Vitals are Google's metrics for measuring user experience. They directly impact your SEO rankings:
- LCP (Largest Contentful Paint) - How fast your main content loads (should be under 2.5s)
- FID (First Input Delay) - How quickly your site responds to user interactions (under 100ms)
- CLS (Cumulative Layout Shift) - How stable your layout is during loading (under 0.1)
Images are the biggest contributors to poor LCP and CLS scores. Lazy loading helps by loading images only when needed, reducing initial page load time dramatically.
Method 1: Native Lazy Loading
The simplest way to lazy load images is using the native
loading attribute. It's supported in all modern
browsers and requires zero JavaScript!
Basic Native Lazy Loading
<!-- Lazy load images below the fold -->
<img
src="product-image.jpg"
alt="Product description"
loading="lazy"
width="800"
height="600"
/>
<!-- Load hero images immediately -->
<img
src="hero-image.jpg"
alt="Hero banner"
loading="eager"
width="1920"
height="1080"
/>
<!-- Let browser decide (default behavior) -->
<img
src="logo.png"
alt="Company logo"
loading="auto"
width="200"
height="60"
/>
Important: Always Include Width and Height
This is crucial for avoiding CLS (layout shift):
<!-- ❌ Bad - Causes layout shift -->
<img src="photo.jpg" alt="Photo" loading="lazy" />
<!-- ✅ Good - Prevents layout shift -->
<img
src="photo.jpg"
alt="Photo"
loading="lazy"
width="800"
height="600"
/>
<!-- ✅ Also good - Use aspect-ratio in CSS -->
<img
src="photo.jpg"
alt="Photo"
loading="lazy"
style="aspect-ratio: 16/9; width: 100%;"
/>
Browser Support
| Browser | Support |
|---|---|
| Chrome | ✅ v77+ |
| Firefox | ✅ v75+ |
| Safari | ✅ v15.4+ |
| Edge | ✅ v79+ |
Method 2: Intersection Observer API
For more control and better browser support, use the Intersection Observer API. This gives you precise control over when images load:
// Basic Intersection Observer lazy loading
class LazyLoader {
constructor() {
this.images = document.querySelectorAll('img[data-src]');
this.observer = null;
this.init();
}
init() {
// Check if IntersectionObserver is supported
if (!('IntersectionObserver' in window)) {
this.loadAllImages();
return;
}
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadImage(entry.target);
}
});
},
{
root: null, // Use viewport as root
rootMargin: '50px', // Load 50px before entering viewport
threshold: 0.01, // Trigger when 1% visible
}
);
this.images.forEach((img) => {
this.observer.observe(img);
});
}
loadImage(img) {
const src = img.getAttribute('data-src');
const srcset = img.getAttribute('data-srcset');
// Create a new image to preload
const tempImg = new Image();
tempImg.onload = () => {
// Image loaded successfully, now update the real img
img.src = src;
if (srcset) {
img.srcset = srcset;
}
img.classList.add('loaded');
img.removeAttribute('data-src');
img.removeAttribute('data-srcset');
// Stop observing this image
this.observer.unobserve(img);
};
tempImg.onerror = () => {
console.error(`Failed to load: ${src}`);
img.classList.add('error');
};
tempImg.src = src;
}
loadAllImages() {
// Fallback for browsers without IntersectionObserver
this.images.forEach((img) => {
const src = img.getAttribute('data-src');
if (src) {
img.src = src;
}
});
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
new LazyLoader();
});
HTML Structure for Intersection Observer
<!-- Use data-src instead of src -->
<img
src="placeholder.jpg"
data-src="actual-image.jpg"
alt="Description"
width="800"
height="600"
class="lazy"
/>
<!-- For responsive images -->
<img
src="placeholder.jpg"
data-src="image-800.jpg"
data-srcset="image-400.jpg 400w,
image-800.jpg 800w,
image-1200.jpg 1200w"
sizes="(max-width: 600px) 100vw, 50vw"
alt="Description"
width="800"
height="600"
class="lazy"
/>
<!-- CSS for smooth transition -->
<style>
img.lazy {
opacity: 0;
transition: opacity 0.3s;
}
img.lazy.loaded {
opacity: 1;
}
img.lazy.error {
opacity: 0.5;
border: 2px solid red;
}
</style>
Method 3: React Lazy Loading Component
For React applications, here's a reusable lazy loading component:
import React, { useEffect, useRef, useState } from 'react';
function LazyImage({ src, alt, width, height, placeholder, className = '' }) {
const [imageSrc, setImageSrc] = useState(placeholder || null);
const [isLoaded, setIsLoaded] = useState(false);
const imgRef = useRef(null);
useEffect(() => {
if (!imgRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setImageSrc(src);
observer.disconnect();
}
},
{
rootMargin: '100px',
}
);
observer.observe(imgRef.current);
return () => {
if (observer) observer.disconnect();
};
}, [src]);
const handleLoad = () => {
setIsLoaded(true);
};
return (
<img
ref={imgRef}
src={imageSrc}
alt={alt}
width={width}
height={height}
onLoad={handleLoad}
className={`${className} ${isLoaded ? 'loaded' : 'loading'}`}
style={{
opacity: isLoaded ? 1 : 0.5,
transition: 'opacity 0.3s',
}}
/>
);
}
// Usage
function Gallery() {
return (
<div>
<LazyImage
src="/images/photo1.jpg"
placeholder="/images/placeholder.jpg"
alt="Photo 1"
width={800}
height={600}
/>
<LazyImage
src="/images/photo2.jpg"
placeholder="/images/placeholder.jpg"
alt="Photo 2"
width={800}
height={600}
/>
</div>
);
}
export default Gallery;
Advanced Technique: Blur-Up Placeholders
Create a better user experience with progressive image loading:
// Generate blur placeholder with canvas
function generateBlurPlaceholder(img) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Set tiny dimensions for blur effect
canvas.width = 20;
canvas.height = 20;
// Draw scaled-down image
ctx.drawImage(img, 0, 0, 20, 20);
// Return as data URL
return canvas.toDataURL();
}
// CSS for blur effect
const blurUpStyle = `
.blur-up {
filter: blur(20px);
transform: scale(1.1);
transition: filter 0.5s, transform 0.5s;
}
.blur-up.loaded {
filter: blur(0);
transform: scale(1);
}
`;
// HTML usage
<img
src="tiny-blurred-version.jpg"
data-src="full-resolution.jpg"
alt="Photo"
class="blur-up"
width="800"
height="600"
/>
Optimizing for Core Web Vitals
1. Improve LCP (Largest Contentful Paint)
<!-- Preload critical above-the-fold images -->
<link
rel="preload"
as="image"
href="hero-image.jpg"
imagesrcset="hero-400.jpg 400w, hero-800.jpg 800w"
imagesizes="100vw"
/>
<!-- Don't lazy load the LCP image! -->
<img
src="hero-image.jpg"
alt="Hero image"
fetchpriority="high"
width="1920"
height="1080"
/>
2. Prevent CLS (Cumulative Layout Shift)
<!-- Method 1: Explicit dimensions -->
<img
src="photo.jpg"
width="800"
height="600"
loading="lazy"
/>
<!-- Method 2: Aspect ratio with CSS -->
<div style="aspect-ratio: 16/9; overflow: hidden;">
<img
src="photo.jpg"
style="width: 100%; height: 100%; object-fit: cover;"
loading="lazy"
/>
</div>
<!-- Method 3: Padding-bottom hack -->
<div style="position: relative; padding-bottom: 56.25%;">
<img
src="photo.jpg"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
loading="lazy"
/>
</div>
Responsive Image Best Practices
Combine lazy loading with responsive images for maximum performance:
<!-- Responsive with lazy loading -->
<picture>
<source
media="(max-width: 600px)"
srcset="small.jpg 1x, small-2x.jpg 2x"
/>
<source
media="(max-width: 1200px)"
srcset="medium.jpg 1x, medium-2x.jpg 2x"
/>
<img
src="large.jpg"
srcset="large.jpg 1x, large-2x.jpg 2x"
alt="Responsive image"
loading="lazy"
width="1200"
height="800"
/>
</picture>
<!-- Modern formats with fallback -->
<picture>
<source type="image/avif" srcset="photo.avif" />
<source type="image/webp" srcset="photo.webp" />
<img
src="photo.jpg"
alt="Photo"
loading="lazy"
width="800"
height="600"
/>
</picture>
Testing and Measuring Performance
// Measure LCP in JavaScript
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP:', lastEntry.renderTime || lastEntry.loadTime);
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
// Track lazy loaded images
document.querySelectorAll('img[loading="lazy"]').forEach((img) => {
img.addEventListener('load', () => {
console.log(`Lazy loaded: ${img.src}`);
});
});
// Use Lighthouse CI for automated testing
// Install: npm install -g @lhci/cli
// Run: lhci collect --url=http://localhost:3000
Common Mistakes to Avoid
❌ Lazy Loading Above-the-Fold Images
<!-- Bad - Delays LCP -->
<img src="hero.jpg" loading="lazy" />
<!-- Good - Loads immediately -->
<img src="hero.jpg" loading="eager" fetchpriority="high" />
❌ Forgetting Image Dimensions
<!-- Bad - Causes CLS -->
<img src="photo.jpg" loading="lazy" />
<!-- Good - Reserves space -->
<img src="photo.jpg" loading="lazy" width="800" height="600" />
❌ Not Providing Placeholders
<!-- Bad - Blank space while loading -->
<img data-src="photo.jpg" />
<!-- Good - Shows placeholder -->
<img src="placeholder.jpg" data-src="photo.jpg" />
Browser Support and Polyfills
// Polyfill for older browsers
if (!('loading' in HTMLImageElement.prototype)) {
// Load IntersectionObserver polyfill
const script = document.createElement('script');
script.src = 'https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserver';
document.head.appendChild(script);
script.onload = () => {
// Initialize custom lazy loader
new LazyLoader();
};
} else {
// Native lazy loading supported
console.log('Native lazy loading enabled');
}
Quick Implementation Checklist
-
✅ Use
loading="lazy"for images below the fold -
✅ Use
loading="eager"for above-the-fold images -
✅ Always specify
widthandheightattributes - ✅ Provide low-quality placeholders
- ✅ Use responsive images with
srcset -
✅ Preload critical images with
<link rel="preload"> - ✅ Test with Lighthouse and PageSpeed Insights
- ✅ Monitor Core Web Vitals in production
- ✅ Consider using modern formats (WebP, AVIF)
- ✅ Add fallback for older browsers
Resources for Further Learning
- web.dev - Google's image optimization guide
- MDN Web Docs - Intersection Observer API
- Lighthouse - Performance auditing tool
- PageSpeed Insights - Real-world performance data
- Can I Use - Browser support checker
Key Takeaways
- Lazy loading can reduce initial page load by 50-70%
- Native lazy loading is the simplest solution
- Always include image dimensions to prevent CLS
- Don't lazy load above-the-fold images
- Use Intersection Observer for advanced control
- Combine with responsive images for best results
- Monitor Core Web Vitals to track improvements
Lazy loading images is one of the easiest wins for web performance. Start with native lazy loading, add dimensions to prevent layout shift, and watch your Core Web Vitals scores improve. Your users will enjoy faster page loads, and Google will reward you with better rankings!
💡 Pro tip: Use Chrome DevTools Network throttling to test lazy loading on slow connections. It's the best way to see the real-world impact of your optimizations!