I ran a Lighthouse audit on a client's e-commerce site last month. It loaded 47 product images on page load — including the ones at the bottom nobody would see unless they scrolled for days. The initial page weight was 8.3 MB. After adding lazy loading properly, it dropped to 1.2 MB. LCP improved by 2.4 seconds. That's the difference between a sale and a bounce.
Lazy loading images isn't complicated, but doing it correctly — without causing layout shifts, broken SEO, or janky user experience — takes some care. Let's get it right.
The Native Way: loading="lazy"
The simplest approach. One attribute, zero JavaScript, works in every modern browser:
<img src="product.jpg"
alt="Blue running shoes"
width="400"
height="300"
loading="lazy">
The browser will defer loading this image until it's about to enter the viewport. The exact threshold varies by browser — Chrome starts loading when the image is roughly 1250px away on fast connections, and 2500px away on slow ones. You don't control this, and honestly, you don't need to.
When NOT to Use loading="lazy"
Here's the mistake I see constantly: people slap loading="lazy" on every single image, including the hero image at the top of the page. That's backwards.
Never lazy load above-the-fold images. Your LCP (Largest Contentful Paint) image needs to load as fast as possible. Making the browser wait to detect it's in the viewport adds unnecessary delay.
<!-- ABOVE THE FOLD — load eagerly (default) -->
<img src="hero-banner.jpg"
alt="Summer sale - 50% off"
width="1200"
height="600"
fetchpriority="high">
<!-- BELOW THE FOLD — lazy load -->
<img src="product-1.jpg"
alt="Product name"
width="400"
height="300"
loading="lazy">
Notice the fetchpriority="high" on the hero image. That tells the browser to prioritize fetching it. Combined with not lazy loading it, your LCP image loads as fast as the browser can manage.
Rule of thumb: The first 1-2 images visible on initial page load should NOT have loading="lazy". Everything else should. If you're unsure whether an image is above the fold, check on mobile — the fold is much higher there.
Intersection Observer — Full Control
Sometimes native lazy loading isn't enough. Maybe you want a custom loading threshold, a fade-in animation, or you're loading background images that loading="lazy" doesn't support. That's where the Intersection Observer API comes in.
<img class="lazy"
src="placeholder.svg"
data-src="actual-image.jpg"
alt="Product photo"
width="400"
height="300">
<script>
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
img.classList.add('loaded');
observer.unobserve(img);
}
});
}, {
rootMargin: '200px 0px' // Start loading 200px before visible
});
document.querySelectorAll('img.lazy').forEach(img => {
observer.observe(img);
});
</script>
The rootMargin: '200px 0px' tells the observer to trigger when the image is within 200px of the viewport — giving images a head start so they're loaded by the time the user scrolls to them. Adjust this value based on how fast your users typically scroll.
Adding a Fade-In Effect
.lazy {
opacity: 0;
transition: opacity 0.3s ease;
}
.loaded {
opacity: 1;
}
This creates a smooth appearance when images load. It's a small touch, but it makes the experience feel polished rather than images popping in abruptly.
Placeholder Strategies
What does the user see before the image loads? A blank space looks broken. A spinner feels slow. The right placeholder creates a smooth visual experience. Here are the approaches I've used, ranked from simplest to most sophisticated.
1. Solid Color Background
The easiest option. Pick a color that matches the image's dominant tone, or use a neutral gray:
.image-wrapper {
background: #e2e8f0;
aspect-ratio: 4/3;
}
.image-wrapper img {
width: 100%;
height: 100%;
object-fit: cover;
}
2. Dominant Color Placeholder
Extract the dominant color from each image at build time and use it as the background. Libraries like sharp (Node.js) or Pillow (Python) can extract dominant colors automatically:
<div class="image-wrapper" style="background: #2d5a3f;">
<img src="forest.jpg" loading="lazy"
alt="Forest landscape" width="800" height="600">
</div>
3. Blur-Up (LQIP)
Load a tiny version of the image (20-40px wide), display it blurred, then swap in the full image. This is what Medium and platforms like Unsplash use:
<div class="image-wrapper">
<!-- Tiny placeholder, inlined as base64 -->
<img class="placeholder"
src="data:image/jpeg;base64,/9j/4AAQ..."
alt="" aria-hidden="true">
<!-- Actual image -->
<img class="lazy full-image"
data-src="photo-full.jpg"
alt="Mountain landscape"
width="800" height="600">
</div>
<style>
.image-wrapper {
position: relative;
aspect-ratio: 4/3;
overflow: hidden;
}
.placeholder {
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;
}
.full-image.loaded {
opacity: 1;
}
</style>
The base64-encoded thumbnail adds maybe 200-300 bytes per image to your HTML, but the perceptual improvement is dramatic. Users see a blurry preview instantly, then the sharp image fades in smoothly.
Modern alternative: CSS image-set() and the <picture> element let you define multiple resolutions natively. For the blur-up technique specifically, check out blurhash — it encodes image placeholders into tiny 20-30 character strings that decode into beautiful blurred previews on the client.
Preventing CLS (Cumulative Layout Shift)
This is where most lazy loading implementations go wrong. When an image without dimensions loads, it goes from 0 height to its natural height, shoving all content below it downward. Google measures this as CLS, and it directly affects your Core Web Vitals score.
The fix is simple: always set width and height on your images.
<!-- ✅ Good: explicit dimensions -->
<img src="photo.jpg"
width="800"
height="600"
loading="lazy"
alt="Description">
<!-- ✅ Also good: CSS aspect-ratio -->
<img src="photo.jpg"
loading="lazy"
alt="Description"
style="aspect-ratio: 4/3; width: 100%; height: auto;">
When you set width and height, modern browsers automatically calculate the aspect ratio and reserve the right amount of space before the image loads. Zero layout shift.
For Responsive Images
img {
max-width: 100%;
height: auto; /* Maintains aspect ratio */
}
/* The browser uses width/height attributes to
calculate the aspect ratio BEFORE loading */
The width and height attributes don't set a fixed pixel size when you have max-width: 100% and height: auto in CSS. They just tell the browser the ratio to reserve space for. This is one of the best features modern browsers have added — and many developers don't realize it exists.
Lazy Loading Background Images
Native loading="lazy" only works on <img> and <iframe> elements. For CSS background images, you need the Intersection Observer approach:
<div class="hero-section" data-bg="hero-bg.jpg">
<h1>Welcome</h1>
</div>
<script>
const bgObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const el = entry.target;
el.style.backgroundImage = `url(${el.dataset.bg})`;
bgObserver.unobserve(el);
}
});
}, { rootMargin: '300px 0px' });
document.querySelectorAll('[data-bg]').forEach(el => {
bgObserver.observe(el);
});
</script>
Set a background color on the element as a fallback so it doesn't flash white while loading.
Convert HTML Layouts to Images
Built a page with optimized images and lazy loading? Capture your layouts as crisp PNG or WebP images for portfolios and case studies.
Try HTML to WebP Converter →The Complete Setup I Use in Production
Here's my go-to setup that handles everything — native lazy loading, CLS prevention, responsive images, and a decent placeholder experience:
<picture>
<source srcset="photo-800.webp 800w,
photo-1200.webp 1200w,
photo-1600.webp 1600w"
sizes="(max-width: 600px) 100vw,
(max-width: 1200px) 50vw,
33vw"
type="image/webp">
<img src="photo-800.jpg"
srcset="photo-800.jpg 800w,
photo-1200.jpg 1200w,
photo-1600.jpg 1600w"
sizes="(max-width: 600px) 100vw,
(max-width: 1200px) 50vw,
33vw"
width="1600"
height="1200"
loading="lazy"
decoding="async"
alt="Descriptive alt text">
</picture>
The decoding="async" attribute tells the browser it's okay to decode the image off the main thread. It won't block rendering while decoding a large JPEG. Small performance win, zero cost to add.
Quick Checklist
- Above-the-fold images: No lazy loading, add
fetchpriority="high"to LCP image - Below-the-fold images: Add
loading="lazy" - All images: Set
widthandheightattributes for CLS prevention - All images: Add
decoding="async" - Responsive images: Use
srcsetandsizeswith WebP format - Background images: Use Intersection Observer
- Placeholders: At minimum use solid color backgrounds; ideally use blur-up
Lazy loading isn't just a "nice to have" anymore — it's table stakes for performance. The good news is that the native loading="lazy" attribute makes the basic case trivially easy. Just remember: don't lazy load what's already visible, always specify dimensions, and choose a placeholder strategy that doesn't make your page feel broken while loading.
Your users on 3G connections will thank you. Your Lighthouse score will thank you. And you'll spend less time debugging "why is the page so slow" tickets.