← All articles 14 min read

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:

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.


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:

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:

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

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

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.


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:


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:

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:

  1. Identify your LCP image. Run Lighthouse or use Chrome DevTools Performance panel. The LCP element should load eagerly with fetchpriority="high".
  2. Add loading="lazy" to all other images. Start with native HTML. This handles 90% of cases with zero JavaScript.
  3. Set width and height on every image. No exceptions. Use actual pixel dimensions, not CSS-only sizing.
  4. 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.
  5. Use responsive images for variable viewports. srcset + sizes + loading="lazy" is the trifecta.
  6. Test with Lighthouse. Check LCP, CLS, and total image transfer size before and after. You should see measurable improvement.
  7. 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.