Lazy Loading Images: The Complete Performance Guide
A page with 40 images loads all 40 on first paint. The user sees three. The other 37 burn bandwidth, block rendering, and tank your Largest Contentful Paint score — all for content nobody has scrolled to yet.
Lazy loading images fixes that. Instead of downloading every image upfront, you defer off-screen images until the user actually needs them. The result: faster initial load, lower bandwidth consumption, and measurably better Core Web Vitals.
The technique has been around for over a decade, but the implementation options have changed dramatically. You can now lazy load with a single HTML attribute, a few lines of JavaScript, or a battle-tested library. Each approach has trade-offs. This article walks through all of them, shows you working code, and helps you pick the right one for your project.
Why Lazy Loading Matters
Images account for roughly 50% of total page weight on the median website, according to HTTP Archive data. On image-heavy pages — portfolios, e-commerce listings, blogs with inline screenshots — that number climbs past 70%.
Without lazy loading, the browser fetches every <img> tag in the HTML as soon as it parses it. On a product listing page with 60 thumbnails, that means 60 concurrent (or queued) HTTP requests before the user has even finished reading the headline.
The performance cost is real:
- Bandwidth waste. Users on mobile connections download megabytes of images they never see. This costs them real money on metered plans.
- Slower LCP. The browser's network queue is saturated with off-screen images, delaying the above-fold content that actually matters.
- Higher bounce rate. Google's data shows that a page load increase from 1s to 3s increases bounce probability by 32%. Images are often the difference between those two numbers.
- Server/CDN cost. Every image request costs egress bandwidth. Lazy loading reduces total image requests by 40-60% on typical pages.
If you are serving images on the web and not lazy loading them, you are leaving performance on the table. The question is not whether to lazy load — it is which approach to use.
Try it yourself
Reduce file size without visible quality loss — free, instant, no signup. Your images never leave your browser.
Lazy Loading Approaches Compared
| Approach | Browser Support | Control Level | Performance Impact | Implementation Complexity |
|---|---|---|---|---|
Native HTML (loading="lazy") |
~96% (all modern browsers) | Low — browser decides thresholds | Good — zero JS, no bundle cost | Trivial — one attribute |
| Intersection Observer API | ~97% | High — custom thresholds, callbacks, animations | Excellent — fine-grained control | Moderate — ~20 lines of JS |
| Scroll event listener | ~100% (legacy) | Medium — manual calculation | Poor — scroll handlers are expensive | High — debounce, offset math, cleanup |
| lazysizes 5.3.2 | ~100% (IE9+) | High — plugins for responsive, LQIP, bgset | Excellent — polished edge cases | Low — add script, use lazyload class |
| vanilla-lazyload 19.1.3 | ~97% (uses Intersection Observer) | Medium-High — lightweight, configurable | Excellent — 2.5 KB gzipped | Low — add script, configure |
The right choice depends on your requirements. For most projects in 2026, native loading="lazy" handles 90% of cases. When you need custom behavior — placeholder animations, eager loading of critical images, or responsive art direction — the Intersection Observer API gives you full control without the baggage of a library.
Native HTML: loading="lazy"
This is the simplest approach and the one you should try first. Add loading="lazy" to any <img> or <iframe> tag, and the browser defers fetching until the element approaches the viewport.
<img
src="product-hero.webp"
alt="Pixotter compression result showing 73% file size reduction"
width="800"
height="600"
loading="lazy"
>
That is the entire implementation. No JavaScript, no library, no build step.
How It Works Under the Hood
When the browser's HTML parser encounters an <img> with loading="lazy", it does not immediately fetch the resource. Instead, it monitors the element's position relative to the viewport. Once the image enters a browser-defined distance threshold (typically 1250px on fast connections, 2500px on slow ones in Chromium), the fetch begins.
The browser makes its own decisions about thresholds based on:
- Connection speed. On 4G or slower, the threshold expands so images start loading earlier to compensate for higher latency.
- Data saver mode. When enabled, thresholds shrink significantly to minimize data usage.
- Element position. The browser's heuristic accounts for scroll velocity and predicted user behavior.
The Width and Height Rule
Always include width and height attributes (or use CSS aspect-ratio) on lazy-loaded images. Without explicit dimensions, the browser cannot reserve space for the image before it loads, which causes Cumulative Layout Shift (CLS) — one of the three Core Web Vitals that directly affects your search ranking.
<!-- Good: dimensions prevent layout shift -->
<img src="chart.webp" alt="Compression ratio chart" width="1200" height="675" loading="lazy">
<!-- Also good: CSS aspect-ratio -->
<img src="chart.webp" alt="Compression ratio chart" loading="lazy" style="aspect-ratio: 16/9; width: 100%;">
<!-- Bad: no dimensions, will cause CLS -->
<img src="chart.webp" alt="Compression ratio chart" loading="lazy">
When Native Is Not Enough
Native loading="lazy" has limitations you should know about:
- No control over thresholds. You cannot tell the browser "start loading 500px before the viewport" vs. 2000px. The browser decides.
- No placeholder support. There is no built-in mechanism for blurred previews, dominant-color placeholders, or skeleton screens.
- No callback. You cannot trigger animations or state changes when an image finishes loading.
- No
<picture>control. Whileloading="lazy"on a<picture>element's<img>tag works, you cannot independently control lazy loading per<source>.
If any of those limitations matter for your use case, the Intersection Observer API is your next step.
Intersection Observer API
The Intersection Observer API lets you detect when elements enter or leave the viewport (or any ancestor element). For lazy loading images, you observe placeholder elements and swap in the real src when they become visible.
Here is a complete, production-ready implementation:
<!-- HTML: use data-src instead of src -->
<img
class="lazy"
data-src="product-photo.webp"
alt="Product photo optimized with Pixotter"
width="800"
height="600"
>
// lazy-load.js — Intersection Observer implementation
document.addEventListener('DOMContentLoaded', () => {
const lazyImages = document.querySelectorAll('img.lazy');
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
// Handle responsive images with srcset
if (img.dataset.srcset) {
img.srcset = img.dataset.srcset;
}
img.classList.remove('lazy');
img.classList.add('loaded');
obs.unobserve(img);
}
});
}, {
// Start loading 200px before the image enters the viewport
rootMargin: '200px 0px',
threshold: 0
});
lazyImages.forEach(img => observer.observe(img));
} else {
// Fallback: load all images immediately
lazyImages.forEach(img => {
img.src = img.dataset.src;
if (img.dataset.srcset) {
img.srcset = img.dataset.srcset;
}
});
}
});
What Each Option Does
rootMargin: '200px 0px'— Expands the observation area by 200px vertically. The image starts loading 200px before it scrolls into view, giving the browser a head start on the fetch. Adjust this based on your average image size and expected scroll speed.threshold: 0— Triggers the callback as soon as any part of the image enters the observation area. A threshold of0.5would wait until 50% of the image is visible.unobserve()— Stops watching the image after it loads. Without this, the observer fires on every scroll past the image, wasting CPU.
Adding a Fade-In Effect
A subtle opacity transition makes the loading experience feel polished:
img.lazy {
opacity: 0;
transition: opacity 0.3s ease-in;
}
img.loaded {
opacity: 1;
}
This pairs with the classList.add('loaded') in the JavaScript above. The image fades in over 300ms after the src is set and the image finishes decoding.
Handling Responsive Images
For <picture> elements with multiple sources, extend the pattern to swap data-srcset on each <source>:
<picture>
<source data-srcset="hero.avif" type="image/avif">
<source data-srcset="hero.webp" type="image/webp">
<img class="lazy" data-src="hero.jpg" alt="Hero banner" width="1600" height="900">
</picture>
// Extended observer callback for <picture> elements
if (entry.isIntersecting) {
const img = entry.target;
const picture = img.closest('picture');
if (picture) {
const sources = picture.querySelectorAll('source[data-srcset]');
sources.forEach(source => {
source.srcset = source.dataset.srcset;
});
}
img.src = img.dataset.src;
img.classList.remove('lazy');
img.classList.add('loaded');
obs.unobserve(img);
}
Scroll Event Listener (Legacy Approach)
Before Intersection Observer existed, developers attached listeners to the scroll event and calculated element positions manually. This approach still works, but it has real performance problems.
// Legacy scroll-based lazy loading — use only if you must support IE11
function lazyLoadScroll() {
const lazyImages = document.querySelectorAll('img.lazy');
const scrollTop = window.pageYOffset;
const windowHeight = window.innerHeight;
lazyImages.forEach(img => {
const imgTop = img.getBoundingClientRect().top + scrollTop;
if (imgTop < scrollTop + windowHeight + 300) {
img.src = img.dataset.src;
img.classList.remove('lazy');
}
});
}
// Debounce to avoid firing on every pixel of scroll
let scrollTimeout;
window.addEventListener('scroll', () => {
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(lazyLoadScroll, 100);
}, { passive: true });
// Also check on initial load
lazyLoadScroll();
Why You Should Avoid This
getBoundingClientRect()forces layout recalculation. Calling it on every scroll event triggers the browser to recalculate element positions, which blocks the main thread.- Debouncing delays loading. The 100ms debounce means images can appear blank for a noticeable duration during fast scrolling.
- No automatic cleanup. You have to manually track which images have loaded and remove the listener when all images are done.
- Poor on mobile. Scroll events fire at inconsistent rates on mobile browsers, especially during momentum scrolling.
Intersection Observer was designed specifically to replace this pattern. Unless you need to support IE11 (which Microsoft ended support for in June 2022), there is no reason to use scroll listeners for lazy loading.
Popular Libraries
If you need a polished solution with edge case handling, these libraries have earned their reputation:
lazysizes 5.3.2
The most popular lazy loading library, with a plugin system that handles responsive images, background images, LQIP (Low-Quality Image Placeholders), and even iframes.
<script src="https://cdn.jsdelivr.net/npm/lazysizes@5.3.2/lazysizes.min.js" async></script>
<img
data-src="product.webp"
class="lazyload"
alt="Product image"
width="400"
height="300"
>
Size: 3.4 KB gzipped (core). Plugins add 1-2 KB each.
lazysizes detects browser support and uses the most efficient loading strategy available. It handles edge cases like images added dynamically via JavaScript, CSS background images, and print stylesheets. The bgset plugin is particularly useful for responsive background images that CSS alone handles awkwardly.
License: MIT.
vanilla-lazyload 19.1.3
A lighter alternative built entirely on Intersection Observer. No legacy fallback, no plugin system — just fast, modern lazy loading.
<script src="https://cdn.jsdelivr.net/npm/vanilla-lazyload@19.1.3/dist/lazyload.min.js"></script>
<script>
const lazyLoadInstance = new LazyLoad({
elements_selector: '.lazy',
threshold: 300
});
</script>
Size: 2.5 KB gzipped.
License: MIT.
When to Use a Library vs. Rolling Your Own
Use native loading="lazy" when you just need basic off-screen deferral. Use the Intersection Observer approach when you need one or two custom behaviors (fade-in, custom threshold). Reach for a library when you need several of: responsive images, LQIP placeholders, background image lazy loading, dynamic element support, and you do not want to write and maintain that code yourself.
Responsive Images + Lazy Loading
Modern responsive image markup and lazy loading are complementary techniques. Responsive images ensure the browser downloads the right size; lazy loading ensures it downloads at the right time.
Here is the combination done correctly:
<img
srcset="photo-400w.webp 400w,
photo-800w.webp 800w,
photo-1200w.webp 1200w"
sizes="(max-width: 600px) 100vw,
(max-width: 1024px) 50vw,
33vw"
src="photo-800w.webp"
alt="Responsive product photo"
width="1200"
height="800"
loading="lazy"
>
The loading="lazy" attribute works with srcset and sizes. The browser first determines which source to download based on viewport width and device pixel ratio, then defers that download until the image approaches the viewport.
Combine this with a modern image format like WebP or AVIF for maximum savings. If your source images are larger than you need, run them through Pixotter's resize tool to generate the exact dimensions for each breakpoint — right in your browser, no upload required. Then compress each variant to squeeze out the remaining bytes.
For a deeper look at choosing the right format for your responsive images, see our guide to the best image format for web.
Measuring the Impact: Core Web Vitals
Lazy loading directly affects two of the three Core Web Vitals that Google uses for ranking:
Largest Contentful Paint (LCP)
LCP measures how long it takes for the largest visible element to render. On most pages, this is a hero image or banner.
The key insight: do NOT lazy load your LCP image. The hero image, the first product photo, the main blog post image — these should load eagerly because they are the largest contentful paint. Lazy loading your LCP image makes it load later, which is the opposite of what you want.
<!-- Hero image: load eagerly (this is likely the LCP element) -->
<img src="hero.webp" alt="Hero banner" width="1600" height="900" fetchpriority="high">
<!-- Below-fold images: lazy load -->
<img src="feature-1.webp" alt="Feature screenshot" width="800" height="600" loading="lazy">
<img src="feature-2.webp" alt="Feature screenshot" width="800" height="600" loading="lazy">
Adding fetchpriority="high" to your LCP image tells the browser to prioritize that fetch over other resources. Combined with lazy loading everything else, this focuses the browser's network capacity on what the user actually sees first.
Cumulative Layout Shift (CLS)
CLS measures visual instability — how much page content shifts around as it loads. Images without explicit dimensions are one of the biggest CLS offenders.
When a lazy-loaded image finishes downloading and the browser does not know its dimensions in advance, the layout suddenly shifts to accommodate the image. This creates a jarring experience and tanks your CLS score.
The fix is simple: always declare width and height on every image, or use CSS aspect-ratio. The browser reserves the correct space before the image loads, eliminating the shift entirely.
For a thorough walkthrough of image optimization for search ranking, including alt text, schema markup, and file naming, read how to optimize images for SEO.
Common Mistakes
These are the mistakes that show up repeatedly in production codebases. Every one of them silently degrades performance or user experience.
1. Lazy Loading Above-the-Fold Images
If an image is visible on initial load without scrolling, lazy loading it adds an unnecessary delay. The browser has to parse the HTML, set up the observer or evaluate the loading attribute, determine the image is already in the viewport, then start the fetch. That round trip costs 50-200ms depending on the browser.
Rule of thumb: the first 2-3 images on any page should NOT have loading="lazy". This includes hero images, logos in the header, and the first content image.
2. Missing Width and Height
Already covered above, but it is the single most common lazy loading mistake. Without dimensions, every lazy-loaded image causes a layout shift when it loads. Run Lighthouse on any page with lazy-loaded images and no dimensions — you will see CLS values above 0.25 (Google's threshold is 0.1).
3. Lazy Loading Critical CSS Background Images
Native loading="lazy" only works on <img> and <iframe> elements. If your hero section uses a CSS background-image, the loading attribute does nothing. For CSS backgrounds, you need either the Intersection Observer approach (adding the background class when visible) or a library like lazysizes with the bgset plugin.
4. No Fallback for JavaScript-Disabled Users
If your lazy loading relies on JavaScript (Intersection Observer or scroll events), users with JavaScript disabled see no images at all — because data-src never gets swapped to src.
The fix is a <noscript> fallback:
<img class="lazy" data-src="photo.webp" alt="Product photo" width="800" height="600">
<noscript>
<img src="photo.webp" alt="Product photo" width="800" height="600">
</noscript>
Native loading="lazy" does not have this problem because the src attribute is present from the start.
5. Forgetting Placeholder Space
Even with correct width and height, an empty rectangle looks odd while the user waits for the image. Consider adding a low-cost placeholder:
- Dominant color. Set the
background-colorof the image container to the image's dominant color. Lightweight and effective. - Blurred thumbnail. Inline a tiny (20-40 byte) base64 image as a
background-image. Generates a blurred preview at near-zero cost. - Skeleton screen. A CSS gradient animation that signals "content is loading."
SEO Implications of Lazy Loading
Googlebot renders JavaScript and handles loading="lazy" natively, so lazy-loaded images are generally discoverable for search indexing. But there are nuances:
- Googlebot uses a viewport of 411x731px. Images below this fold are lazy-loaded, so Googlebot must scroll to discover them. Googlebot does scroll, but if your Intersection Observer uses a very small
rootMargin, images far down long pages may not load during the crawl budget. - Native
loading="lazy"is the safest choice for SEO. Because thesrcattribute is present in the HTML, search engines can discover the image URL even without rendering the page. JavaScript-based approaches that rely ondata-srcrequire rendering. - Always include descriptive
alttext. Lazy loading changes when the image loads, not whether search engines can read youralttext. Thealtattribute is in the HTML regardless of loading strategy. - Image sitemaps still work. If you have an XML sitemap for images, lazy loading does not interfere. The sitemap points to the image URL directly.
The short version: use native loading="lazy" for the best SEO outcome. If you use JavaScript-based lazy loading, make sure <noscript> fallbacks provide the real src for crawlers that do not execute JavaScript.
To understand how image sizing connects to overall web performance, check our guide on image sizes for websites.
Putting It All Together: A Practical Checklist
Here is the implementation sequence for a typical website:
- Identify your LCP image. Run Lighthouse or use Chrome DevTools Performance panel. The LCP element should load eagerly with
fetchpriority="high". - Add
loading="lazy"to all other images. Start with native HTML. This handles 90% of cases with zero JavaScript. - Set
widthandheighton every image. No exceptions. Use actual pixel dimensions, not CSS-only sizing. - Compress your images before serving them. Lazy loading reduces the number of image requests; compression reduces the size of each request. Both matter. Pixotter's compressor processes images locally in your browser — drop your images in and get optimized files back instantly.
- Use responsive images for variable viewports.
srcset+sizes+loading="lazy"is the trifecta. - Test with Lighthouse. Check LCP, CLS, and total image transfer size before and after. You should see measurable improvement.
- Add Intersection Observer only if needed. Custom thresholds, fade-in animations, or placeholder handling — reach for JavaScript when native falls short.
FAQ
Does loading="lazy" work in all browsers?
As of 2026, loading="lazy" is supported in Chrome, Edge, Firefox, Safari, and Opera — covering roughly 96% of global users. The remaining 4% (mostly older mobile browsers) simply ignore the attribute and load images eagerly, which is safe fallback behavior.
Should I lazy load images that are already optimized and small?
Yes. Even a 15 KB image costs a network round trip. On a page with 30 small thumbnails, that is 30 HTTP requests competing with your above-fold content. Lazy loading defers those requests regardless of file size.
Can I use loading="lazy" on background images?
No. The loading attribute only works on <img> and <iframe> elements. For CSS background images, use the Intersection Observer API to add the background class when the container scrolls into view, or use a library like lazysizes 5.3.2 with the bgset plugin.
Does lazy loading hurt SEO?
Not when implemented correctly. Native loading="lazy" is the safest option because the image src is in the HTML from the start. JavaScript-based approaches should include <noscript> fallbacks. Googlebot handles both, but native loading requires no rendering to discover image URLs.
How far from the viewport should images start loading?
For native loading="lazy", the browser decides (typically 1250-2500px depending on connection speed). For Intersection Observer, a rootMargin of 200px to 400px works well for most sites — enough lead time to fetch the image before it scrolls into view, without loading too many images at once.
What is the difference between loading="lazy" and loading="eager"?
loading="eager" is the default browser behavior — fetch the image immediately when the HTML is parsed. You only need to set it explicitly when overriding a default lazy loading policy (for example, if a CMS applies loading="lazy" globally and you need to exempt the hero image).
Should I lazy load images in a carousel or slider?
Lazy load images that are not in the initial visible slide. The first visible slide image should load eagerly. For subsequent slides, Intersection Observer gives you more control than native lazy loading — you can preload the next slide's image based on user interaction rather than scroll position.
Does lazy loading work with WebP and AVIF images?
Yes. Lazy loading is format-agnostic — it controls when the browser fetches the file, not how it decodes it. Use loading="lazy" with any image format. For guidance on choosing the right format to pair with lazy loading, see our WebP compression guide.
Summary
Lazy loading images is one of the highest-impact, lowest-effort performance optimizations available. Add loading="lazy" to your off-screen images, set explicit dimensions, keep your LCP image eager, and you have already captured most of the benefit.
When you need more control — custom thresholds, placeholder animations, responsive source swapping — the Intersection Observer API gives you everything without the performance cost of scroll listeners or the weight of a library.
The performance chain is: serve the right format, at the right size, at the right time. Lazy loading handles the timing. Pixotter handles the format and size — compress, resize, and convert your images locally in the browser, then let lazy loading deliver them exactly when your users need them.
Try it yourself
Resize to exact dimensions for any platform — free, instant, no signup. Your images never leave your browser.