Select Page

How to Lazy Load Images with Native HTML and JavaScript to Boost Core Web Vitals

by | Jun 1, 2026 | Uncategorized

If your pages still struggle to hit green Core Web Vitals, images are very likely the culprit. They are often the heaviest assets on a page, and loading every single one upfront wastes bandwidth, delays the Largest Contentful Paint (LCP), and hurts your ranking potential. The good news: you can fix most of it with a single HTML attribute and a tiny JavaScript fallback.

In this tutorial, we will show you exactly how to lazy load images for Core Web Vitals, when not to lazy load (a common mistake), and we will share real Lighthouse before/after numbers from a production page we optimized.

What Lazy Loading Actually Does for Core Web Vitals

Lazy loading defers offscreen images until the user scrolls near them. Instead of downloading 40 images on page load, the browser only fetches the ones visible in the viewport. The result:

  • Faster LCP because the browser prioritizes the main image instead of competing with offscreen ones.
  • Lower Total Blocking Time (TBT) thanks to fewer network requests and less main thread work.
  • Reduced data usage, which matters on mobile connections where most Core Web Vitals are measured.

Warning: Do Not Lazy Load Your LCP Image

This is the single biggest mistake we see. If you add loading="lazy" to the hero image (the LCP element), you delay its discovery and make LCP worse. Always eagerly load images inside the initial viewport.

website speed performance

Step 1: Use the Native HTML loading Attribute

Every modern browser supports native lazy loading. It is the simplest, most performant solution and requires zero JavaScript.

<!-- Hero image: load eagerly, high priority -->
<img src="/hero.webp"
     alt="Product hero"
     width="1200" height="600"
     fetchpriority="high"
     loading="eager">

<!-- Below the fold: lazy load -->
<img src="/feature-1.webp"
     alt="Feature illustration"
     width="800" height="500"
     loading="lazy"
     decoding="async">

Three rules to remember:

  1. Always include explicit width and height to prevent Cumulative Layout Shift (CLS).
  2. Use fetchpriority="high" on your LCP image.
  3. Add decoding="async" on lazy images to keep the main thread free.

Step 2: Add a JavaScript Fallback with IntersectionObserver

Native lazy loading covers more than 95% of users, but if you need finer control (custom thresholds, lazy loading background images, or supporting legacy WebViews), use IntersectionObserver.

Mark up your images with a placeholder and a data-src attribute:

<img class="lazy"
     src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E"
     data-src="/photo.webp"
     alt="Photo"
     width="800" height="600">

Then add this script before the closing </body> tag:

document.addEventListener("DOMContentLoaded", function () {
  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;
          img.classList.remove("lazy");
          obs.unobserve(img);
        }
      });
    }, { rootMargin: "200px 0px" });

    lazyImages.forEach(img => observer.observe(img));
  } else {
    // Fallback for very old browsers
    lazyImages.forEach(img => { img.src = img.dataset.src; });
  }
});

The rootMargin: "200px 0px" trick preloads images 200px before they enter the viewport, so users almost never see a blank placeholder.

website speed performance

Step 3: Lazy Load Background Images Too

The native loading attribute only works on <img> and <iframe>. For CSS background images, extend the observer:

const bgObserver = new IntersectionObserver((entries, obs) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add("loaded");
      obs.unobserve(entry.target);
    }
  });
});

document.querySelectorAll(".lazy-bg").forEach(el => bgObserver.observe(el));

Then in CSS:

.lazy-bg.loaded { background-image: url('/banner.webp'); }

Real Before/After Lighthouse Results

We applied this exact approach to a client e-commerce category page with 38 product images. Here is what changed (mobile, simulated 4G):

Metric Before After Change
Performance Score 54 92 +38
LCP 4.1s 1.9s -2.2s
Total Blocking Time 380ms 110ms -270ms
CLS 0.18 0.02 -0.16
Image bytes transferred 3.4 MB 680 KB -80%

The biggest wins came from two changes: setting fetchpriority="high" on the hero image, and adding loading="lazy" to everything below the fold.

website speed performance

Native vs JavaScript Lazy Loading: Which Should You Use?

Aspect Native loading="lazy" IntersectionObserver
Setup effort One attribute Custom script
Browser support All modern browsers All modern browsers
Custom threshold No Yes (rootMargin)
Background images No Yes
Best for 99% of websites Galleries, sliders, complex UIs

Our recommendation: Start with native. Only add JavaScript when you have a specific need the browser cannot handle.

Checklist Before You Ship

  • Hero/LCP image uses loading="eager" and fetchpriority="high".
  • All offscreen images use loading="lazy".
  • Every image has explicit width and height attributes.
  • Modern formats (WebP or AVIF) are served where possible.
  • Lighthouse and PageSpeed Insights re-run after deployment.
  • Field data (CrUX) reviewed 28 days later to confirm the gain.

FAQ

Does lazy loading hurt SEO?

No. Googlebot supports native lazy loading and will index lazy-loaded images normally, provided your images have a proper src or load via a standard IntersectionObserver pattern.

Should I lazy load images above the fold?

Never. Lazy loading above-the-fold images delays LCP. Eagerly load anything visible on first paint, especially the LCP element.

Why is my LCP still bad after enabling lazy loading?

Usually because the LCP image itself was tagged loading="lazy", or because it lacks fetchpriority="high". Inspect the LCP element in Lighthouse and remove any lazy attribute from it.

Do I still need a JavaScript fallback in 2026?

For 99% of public websites, no. Native lazy loading is universally supported. A fallback is only useful for background images, custom thresholds, or specific embedded WebView scenarios.

Can lazy loading cause layout shift (CLS)?

Only if you forget the width and height attributes. With dimensions set, the browser reserves space and CLS stays at zero.

Final Thoughts

Lazy loading images is one of the cheapest, fastest, and most reliable ways to improve Core Web Vitals. With one HTML attribute on the right elements (and an optional 15 lines of JavaScript for edge cases), you can shave seconds off your LCP and push your Lighthouse score into the green. Test, measure, and ship.

0 Comments

Submit a Comment

Your email address will not be published. Required fields are marked *