Here's something silly: most websites load every image on the page the exact moment a user arrives, regardless of whether those images are anywhere near visible. A product page might have 60 product photos. The user sees 4. The browser downloaded all 60. That's not smart.
Lazy loading fixes this. It defers loading images and iframes until they're actually close to the viewport. Less bandwidth wasted. Faster initial page load. Better LCP. The fix has been native in all major browsers since 2020. There's almost no reason not to be using it.
But there's one mistake I see constantly that actually makes performance worse. So let's do this properly.
- Native lazy loading: the right way
- The mistake that hurts LCP (never do this)
- fetchpriority: telling the browser what matters
- IntersectionObserver: when you need custom behavior
- Lazy loading CSS background images
- Lazy loading iframes and video embeds
- WordPress lazy loading (what actually works)
- Does lazy loading hurt SEO?
- The complete checklist
Native Lazy Loading: The Right Way
The native browser API is a single attribute: loading="lazy". Add it to any
<img> or <iframe> tag, and the browser handles the rest. No JavaScript
library. No third-party dependency. No configuration required.
<!-- This image loads only when the user scrolls near it -->
<img
src="product-photo.webp"
alt="Product from our store showing blue color variation"
width="800"
height="600"
loading="lazy"
decoding="async">
The browser starts loading the image when it gets within a certain distance of the viewport (the "threshold"). This threshold varies by browser but is roughly 1-2 screen heights below the current view — which means images start loading before the user actually reaches them, so there's no visible blank square when they arrive.
Browser support: Chrome, Edge, Firefox, Safari, Opera — all support it. You can just use it. The question isn't "is this safe?" The question is "why haven't you added this yet?"
Width and height matter more than you think
Always specify width and height on lazy-loaded images. This lets the browser
reserve the correct amount of space before the image loads, preventing layout shift (CLS). Without
dimensions, the page jumps as images load in — which is the exact thing you're supposed to be preventing.
<!-- Good: browser reserves space, no layout shift -->
<img src="feature.webp" alt="..." width="1200" height="630"
loading="lazy" decoding="async">
<!-- Bad: no dimensions, causes layout shift when loaded -->
<img src="feature.webp" alt="..."
loading="lazy">
The complete attribute set for a correctly optimized lazy-loaded image looks like this. Save this code. Put it everywhere.
<img
src="image.webp"
srcset="image-600.webp 600w, image-1200.webp 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
alt="Concise, descriptive alt text with natural keyword"
width="1200"
height="630"
loading="lazy"
decoding="async">
The Mistake That Hurts LCP (Never Do This)
Never lazy load your hero image
If you apply loading="lazy" to the image at the top of your page — the hero image, the main
photo, the banner — you will actively hurt your LCP score. The browser will delay downloading it until
"needed," but it was needed immediately. This is the #1 lazy loading mistake.
I see this in audits constantly. Someone added a WordPress plugin that lazy-loads "all images." The plugin
doesn't know your hero image is above the fold. It just adds loading="lazy" to everything. LCP
gets worse, not better. The optimization backfired.
The rule is extremely simple: lazy load images below the fold. Never lazy load images above the fold.
Above the fold = visible without scrolling. For most layouts, that's the hero image, the logo, and the first screen of content. Everything else is below the fold and should be lazy loaded.
| Image | loading attribute | Why |
|---|---|---|
| Hero / banner image | loading="eager" |
Visible immediately, must load fast for LCP |
| Logo | loading="eager" |
Above the fold, part of initial render |
| First product image | loading="eager" |
If visible without scrolling, load eagerly |
| Section images (below fold) | loading="lazy" |
Not visible initially, save bandwidth |
| Blog post images (body) | loading="lazy" |
User must scroll to reach them |
| Gallery images | loading="lazy" |
Load on demand as user scrolls |
| Product thumbnails (below fold) | loading="lazy" |
Load as user browses |
fetchpriority: Telling the Browser What Matters
The fetchpriority attribute (also known as Priority Hints) takes browser image loading one step
further. It tells the browser which images are most important so it can allocate bandwidth accordingly.
<!-- LCP image: tell the browser this is the most important thing -->
<img src="hero.webp" alt="..."
width="1200" height="630"
loading="eager"
fetchpriority="high">
<!-- Product images lower on page: load lazily, low priority -->
<img src="product-3.webp" alt="..."
width="800" height="600"
loading="lazy"
decoding="async"
fetchpriority="low">
Why does this matter? Browsers have a complex internal priority system for downloading resources. CSS, JavaScript, HTML, fonts, and images all compete for bandwidth. By explicitly marking your hero image as high priority, you're giving it pre-emptive access to available bandwidth, which can meaningfully reduce LCP time — especially on slower connections where bandwidth is limited.
The three values for fetchpriority:
high— Boosted priority. Use on your LCP image only.auto— Browser decides (default behavior).low— Reduced priority. Can be used on below-fold images as a hint.
IntersectionObserver: When You Need Custom Behavior
Native lazy loading handles 90% of use cases perfectly. But there are situations where you want more control:
- Custom loading animations (fade in, progressive reveal)
- Lazy loading CSS background images (native lazy loading only works on
<img>and<iframe>) - Custom threshold distances
- Lazy loading custom elements or non-standard embeds
For these cases, the IntersectionObserver API is the right tool:
// Lazy load images with a custom fade-in animation
const lazyImages = document.querySelectorAll('img[data-src]');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const img = entry.target;
// Swap data-src to src to start loading
img.src = img.dataset.src;
// Optional: swap srcset too
if (img.dataset.srcset) {
img.srcset = img.dataset.srcset;
}
// Fade in when loaded
img.addEventListener('load', () => {
img.style.opacity = '1';
});
// Stop observing this image
observer.unobserve(img);
});
}, {
rootMargin: '0px 0px 400px 0px', // Start loading 400px before entering viewport
threshold: 0
});
lazyImages.forEach(img => {
img.style.opacity = '0';
img.style.transition = 'opacity 0.3s ease';
observer.observe(img);
});
In your HTML, use data-src instead of src for images you're lazy loading this way:
<img
data-src="gallery-photo.webp"
data-srcset="gallery-photo-600.webp 600w, gallery-photo-1200.webp 1200w"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 800 600'%3E%3C/svg%3E"
alt="Gallery photo showing mountain landscape at sunset"
width="800" height="600">
The src is a tiny inline SVG placeholder (basically invisible), so the browser has something to
show immediately while the real image loads. The observer swaps in the real source when the image is near
the viewport.
Lazy Loading CSS Background Images
Native loading="lazy" doesn't work on CSS background images — it only applies to
<img> and <iframe> elements. If you have large background images in
sections below the fold, you need to handle this separately.
The cleanest approach: use a CSS class that's added by JavaScript when the section enters the viewport.
/* CSS: no background initially */
.lazy-bg {
background-image: none;
}
/* When JS adds 'loaded' class, background loads */
.lazy-bg.loaded {
background-image: url('section-background.webp');
}
// JavaScript: observe sections with lazy backgrounds
const lazyBgs = document.querySelectorAll('.lazy-bg');
const bgObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('loaded');
bgObserver.unobserve(entry.target);
}
});
}, { rootMargin: '0px 0px 300px 0px' });
lazyBgs.forEach(el => bgObserver.observe(el));
This ensures the background image only downloads when the section is close to the viewport, not on initial page load.
Lazy Loading Iframes and Video Embeds
YouTube embeds and Google Maps are two of the most common causes of slow pages. A single embedded YouTube video loads 500KB-1MB of YouTube JavaScript on your page, even if the user never clicks play. That's bad for LCP, bad for INP, bad for everything.
Native lazy loading works on iframes:
<iframe
src="https://www.youtube.com/embed/VIDEO_ID"
title="Core Web Vitals explanation video"
width="560" height="315"
loading="lazy"
allowfullscreen>
</iframe>
But even better: use a facades approach for YouTube. Show a static thumbnail that looks like the player, and only load the actual iframe when the user clicks play.
<div class="video-facade" data-video="VIDEO_ID"
style="position: relative; cursor: pointer;">
<!-- Shows real YouTube thumbnail, loads nothing from YouTube -->
<img
src="https://i.ytimg.com/vi/VIDEO_ID/maxresdefault.jpg"
alt="Click to play: Core Web Vitals explanation video"
width="560" height="315"
loading="lazy"
decoding="async">
<!-- Play button overlay -->
<div class="play-btn">▶</div>
</div>
<script>
document.querySelectorAll('.video-facade').forEach(facade => {
facade.addEventListener('click', function() {
const id = this.dataset.video;
this.innerHTML = `<iframe
src="https://www.youtube.com/embed/${id}?autoplay=1"
width="560" height="315"
allow="autoplay; fullscreen"
allowfullscreen></iframe>`;
});
});
</script>
This pattern (called "lazy video facade") means zero YouTube JavaScript loads until the user actually decides to watch. I've seen this save 800KB of JavaScript on pages with a single embedded video.
WordPress Lazy Loading: What Actually Works
WordPress has added native lazy loading to all images by default since WordPress 5.5. That means any images
added through the media library get loading="lazy" automatically.
The problem: WordPress also automatically adds loading="lazy" to the first hero image in a
template — which is exactly what you don't want. To override this, you need to explicitly set the hero image
to eager in your theme:
<?php
// In your theme's template file, override WordPress's default loading="lazy"
the_post_thumbnail('full', array(
'loading' => 'eager',
'fetchpriority' => 'high',
'decoding' => 'async'
));
?>
If you're using a plugin like WP Rocket or Flying Images:
- WP Rocket: In Media settings, you can exclude specific images from lazy loading using a CSS class or file pattern. Exclude your hero image selector.
- Flying Images: Has an option to exclude images above the fold. Enable this — it prevents the plugin from lazy-loading your LCP image.
- Smush: Similar exclusion options under Smush > Lazy Load > Exclude.
Does Lazy Loading Hurt SEO?
Short answer: no. Native loading="lazy" does not negatively affect SEO.
Google's crawler (Googlebot) runs on a version of Chrome and handles lazy loading the same way a real browser does. It scrolls through pages to trigger lazy loading, so your lazy-loaded images will be crawled and indexed normally.
What matters for image SEO is:
- Every image has a descriptive
altattribute - Images have defined
widthandheightattributes - Images use descriptive filenames (not
IMG_3847.jpg) - Important images appear in your sitemap's image entries
- The page content surrounding the image is contextually relevant
JavaScript-based lazy loading (IntersectionObserver) is also crawlable by Google, as long as the images have
src attributes or data-src attributes that Googlebot can find. For the best results, use native
loading="lazy" where possible — it's more reliable for crawlers than custom JavaScript
solutions.
Tip: Image Sitemaps
For websites where images are the primary content (photographers, e-commerce), add image entries to your
sitemap using the <image:image> extension. This gives Google explicit information
about each image's location, caption, and title — which can improve image search traffic significantly.
The Complete Lazy Loading Checklist
Lazy Loading Done Right
-
1
Hero/LCP image: loading="eager" fetchpriority="high" This is the single most important rule. Never lazy load the image at the top of your page.
-
2
All below-fold images: loading="lazy" decoding="async" Every image that requires scrolling to reach should use native lazy loading.
-
3
All images: width and height attributes required Without dimensions, lazy loading causes CLS (layout shift). Always specify actual pixel dimensions.
-
4
YouTube/Maps iframes: use a facade approach Show a static thumbnail, load the real iframe on click. Saves 500KB-1MB per embedded video on initial load.
-
5
CSS background images below fold: use IntersectionObserver Native lazy loading doesn't apply to CSS backgrounds. Use the class-swap IntersectionObserver pattern above.
-
6
WordPress: override hero image loading attribute explicitly WordPress 5.5+ auto-applies loading="lazy" to all images including heroes. Override with loading="eager" in your theme.
-
7
Descriptive alt text on every lazy-loaded image Lazy loading is orthogonal to image SEO. Every image still needs descriptive, keyword-natural alt text.
The Performance Impact
What numbers should you expect from implementing lazy loading correctly? Here's a rough guide:
- Page weight reduction: On image-heavy pages (product listings, portfolios), 40-70% reduction in initial page weight. A 5MB gallery page might only load 400KB initially.
- LCP improvement: When combined with proper
fetchpriority="high"on the hero image, 0.3-0.8s LCP improvement from reduced bandwidth competition. - Bandwidth savings: On average, only 20-30% of below-fold images are ever scrolled to and viewed. Lazy loading means you're not paying bandwidth costs for the 70% users never reach.
- Time to Interactive: Slightly improved since fewer network requests compete during the critical loading window.
These are conservative estimates. On pages with lots of images, the gains are much larger. A WooCommerce shop page with 50 product thumbnails that gets lazy loading implemented correctly will see dramatic improvement in both measured scores and user experience.
See exactly which images on your site need fixing
Run your URL through VitalsFixer and get a specific list of images that are too large, missing alt text, or loaded without lazy loading.
Analyze My Site Free