Lazy Loading Images and Videos: The Complete Native Guide

Why load 47 images when visitors only see 3? Native lazy loading, IntersectionObserver, fetchpriority, and the common mistakes explained once and for all.

Illustration of a webpage with images appearing only as the user scrolls down, representing native lazy loading behavior

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.

In this guide:
  1. Native lazy loading: the right way
  2. The mistake that hurts LCP (never do this)
  3. fetchpriority: telling the browser what matters
  4. IntersectionObserver: when you need custom behavior
  5. Lazy loading CSS background images
  6. Lazy loading iframes and video embeds
  7. WordPress lazy loading (what actually works)
  8. Does lazy loading hurt SEO?
  9. 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:

IntersectionObserver: When You Need Custom Behavior

Native lazy loading handles 90% of use cases perfectly. But there are situations where you want more control:

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:

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:

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

The Performance Impact

What numbers should you expect from implementing lazy loading correctly? Here's a rough guide:

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
VF

VitalsFixer Lab

The "hero image getting accidentally lazy-loaded by a plugin" is something we fix in real audits at least twice a week. This guide exists to save you from that pain.

Keep Reading