Lazy Loading Images: The Complete Implementation Guide
How to implement lazy loading with native HTML, Intersection Observer, and modern frameworks like React, Next.js, and Astro.
Why are you loading 45 images when your user can only see 4?
That’s what most websites do. Every image downloads on page load, whether it’s visible or not. According to Google, a page that takes 5 seconds to load sees a 90% increase in bounce rate compared to one that loads in 1 second. Images are usually the heaviest assets on the page, and loading them all upfront is one of the most common performance mistakes out there.
Lazy loading fixes this. It defers offscreen images until the user actually scrolls to them. This guide covers every major implementation approach, the gotchas that trip people up, and how to get the best results across frameworks.
What Is Lazy Loading?
Lazy loading delays non-critical resources until they enter (or are about to enter) the viewport. For images, the browser only downloads what the user can see, then fetches more as they scroll.
The payoff is huge:
- Faster initial page load: Fewer HTTP requests, less bandwidth consumed on first paint.
- Lower LCP: The browser focuses its resources on above-the-fold content instead of fighting over bandwidth with offscreen images.
- Less wasted bandwidth: Users on mobile or metered connections only download what they actually view.
- Better Core Web Vitals: Pages that lazy load correctly see real, measurable improvements.
How much difference does it make? Sites with image-heavy layouts (product galleries, editorial pages, portfolios) routinely see 40-60% less initial page weight after adding lazy loading.
Native Lazy Loading With loading="lazy"
This is the simplest approach. One HTML attribute, zero JavaScript, supported in all modern browsers (Chrome, Firefox, Edge, Safari 15.4+):
<img src="product-photo.jpg" alt="Red running shoe" width="800" height="600" loading="lazy">
That’s it. The browser handles everything, using its own heuristics to decide when to fetch each image based on scroll position, connection speed, and viewport distance.
Three rules you need to follow
- Always set
widthandheight(or use CSSaspect-ratio). Without dimensions, the browser can’t reserve space, and you get layout shifts (bad CLS scores). - Never lazy load your LCP image. The hero image or largest above-the-fold image must load immediately. Adding
loading="lazy"to it will delay your most important Core Web Vital. - It works on iframes too. Embedded videos and maps can use the same attribute.
<!-- Above the fold: load eagerly (default behavior) -->
<img src="hero-banner.jpg" alt="Summer sale banner" width="1200" height="400" fetchpriority="high">
<!-- Below the fold: lazy load -->
<img src="product-1.jpg" alt="Blue denim jacket" width="400" height="500" loading="lazy">
<img src="product-2.jpg" alt="White sneakers" width="400" height="500" loading="lazy">
Pair fetchpriority="high" with your hero image to tell the browser it’s the most important resource on the page.
The Intersection Observer Approach
Need more control? Custom thresholds, loading animations, placeholder transitions? The Intersection Observer API is the standard JavaScript solution:
document.addEventListener('DOMContentLoaded', () => {
const images = document.querySelectorAll('img[data-src]');
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
if (img.dataset.srcset) {
img.srcset = img.dataset.srcset;
}
img.removeAttribute('data-src');
obs.unobserve(img);
}
});
}, {
rootMargin: '200px 0px' // Start loading 200px before viewport
});
images.forEach(img => observer.observe(img));
});
The HTML uses data-src instead of src:
<img data-src="product-photo.jpg" alt="Product photo" width="400" height="300">
Pay attention to rootMargin. Setting it to 200px means images start loading before they scroll into view, so users don’t see blank spaces. For pages where people scroll fast, bump this value higher.
When NOT to Lazy Load
Lazy loading everything is a common mistake. These images should always load immediately:
- LCP images: Your largest above-the-fold image. Lazy loading it delays the most important metric.
- Above-the-fold content: Anything visible without scrolling.
- Background images critical to layout: CSS backgrounds that appear on first render.
- Logo and navigation images: Small, critical UI elements.
Good rule of thumb: open Chrome DevTools, run the Performance panel, identify the LCP element, and make sure that image (and everything above it) loads eagerly.
Placeholder Strategies
What do users see while a lazy-loaded image hasn’t arrived yet? An empty void looks broken. Placeholders fix that.
Blur-up (LQIP)
Load a tiny version of the image (10-40px wide, heavily compressed), display it blurred, then transition to the full image. This is the approach Medium popularized.
<div class="image-wrapper">
<img src="product-tiny.jpg" alt="Product" class="placeholder blur" width="400" height="300">
<img data-src="product-full.jpg" alt="Product" class="full-image" loading="lazy" width="400" height="300">
</div>
.placeholder.blur {
filter: blur(20px);
transform: scale(1.1);
transition: opacity 0.3s;
}
Dominant Color
Extract the dominant color from each image and use it as a background. Lightweight and no extra network requests:
<img data-src="product.jpg" alt="Product" style="background-color: #2a4d6e;" loading="lazy" width="400" height="300">
Image CDNs like Sirv can generate dominant color placeholders and low-quality previews automatically, so you skip building a preprocessing pipeline yourself.
Framework-Specific Implementations
React (native lazy loading)
function ProductImage({ src, alt }) {
return (
<img
src={src}
alt={alt}
loading="lazy"
decoding="async"
width={400}
height={300}
/>
);
}
Next.js
Next.js lazy loads images by default through its <Image> component. You opt out for priority images, not in:
import Image from 'next/image';
// Lazy loaded by default
<Image src="/products/shoe.jpg" alt="Running shoe" width={400} height={300} />
// Above-the-fold: disable lazy loading
<Image src="/hero.jpg" alt="Hero banner" width={1200} height={400} priority />
Astro
Astro’s built-in <Image> component handles lazy loading too:
---
import { Image } from 'astro:assets';
import productImg from '../assets/product.jpg';
---
<Image src={productImg} alt="Product photo" loading="lazy" />
For the hero image, set loading="eager" explicitly.
How Image CDNs Supercharge Lazy Loading
Modern image CDNs go beyond just deferring the load. When you serve images through a CDN like Sirv, several things happen automatically:
- Responsive resizing: The CDN delivers the right size for each device, so lazy-loaded images are already smaller than they’d otherwise be.
- Format negotiation: WebP or AVIF based on browser support, cutting file size 25-50% before the image even starts loading.
- Quality optimization: Automatic compression with no visible quality loss.
- Global edge caching: When the image does load, it comes from the nearest edge server.
These optimizations stack with lazy loading. Picture a product page with 20 images. You defer 16 of them. The 4 that load immediately are already optimized, compressed, and edge-cached. The page feels instant.
Measuring the Impact
After implementing lazy loading, check the numbers:
- Lighthouse: Run an audit and check LCP, CLS, and Total Blocking Time.
- WebPageTest: Compare waterfall charts before and after. You should see way fewer image requests in the initial load.
- Chrome DevTools Network tab: Filter by images and confirm offscreen images only load on scroll.
- Real User Monitoring (RUM): Google’s CrUX report shows field data from actual users, not lab simulations.
Here’s what typical results look like on image-heavy pages:
| Metric | Before | After |
|---|---|---|
| Initial requests | 45 | 12 |
| Page weight | 4.2 MB | 1.1 MB |
| LCP | 3.8s | 1.9s |
| Speed Index | 4.1s | 2.3s |
Quite impressive for adding one HTML attribute to a bunch of <img> tags.
Your Next Move
Start here: add loading="lazy" to every image below the fold. Add fetchpriority="high" to your hero image. Set explicit width and height on all images to prevent layout shifts. You can do all of this in 30 minutes.
If you need finer control, reach for the Intersection Observer API. And if you want the full optimization stack handled for you (responsive sizing, format conversion, global delivery, and lazy loading integration), serve your images through Sirv’s image CDN. It handles the heavy lifting so you can focus on building your product.