Browse by section

Web Design 日本語

How to Optimise Lazy Loading with the Intersection Observer API

For lazy loading images, the HTML loading="lazy" attribute is the right answer in 2026. Every modern browser supports it, and you write zero JavaScript.

You still need the Intersection Observer API for the cases loading="lazy" cannot reach: CSS background images, video, heavy third-party embeds, custom placeholder animations, and impression measurement.

This article draws that line first, then covers the Intersection Observer implementations you actually need—and why lazy-loading an above-the-fold image wrecks your LCP.

Sponsored

What lazy loading actually improves

Lazy loading works because it defers downloading resources that are not needed for the initial render. It reduces transferred bytes and network contention.

Google’s 2017 mobile speed report showed how directly load time maps to abandonment:

Page load time Increase in bounce rate
1s → 3s about 32%
1s → 5s about 90%
1s → 10s about 123%

Source: Google, “The need for mobile speed” (2017). The figures are old, but the direction still matches how Core Web Vitals treat load performance.

First, check whether loading=”lazy” is enough

For <img> and <iframe>, one attribute finishes the job. Chrome, Edge, Firefox, and Safari all support it.

<img src="photo.jpg" width="800" height="600" alt="Sample"
     loading="lazy" decoding="async">

<iframe src="https://www.youtube.com/embed/xxxx"
        width="560" height="315" loading="lazy"
        title="Product video"></iframe>

Three things belong with it:

  • Write width and height so the box is reserved and you avoid layout shift (CLS)
  • decoding="async" so image decoding does not block the main thread
  • Never put loading="lazy" on an above-the-fold image—it reliably damages LCP

Where the native attribute does not reach

Target loading="lazy" Intersection Observer
<img> / <iframe> Sufficient Not needed
CSS background image Not supported Swap a class
<video> loading Only preload Start playback in view
Fade-in on load No Yes
Ads, maps, heavy scripts No Initialise just before display
Impression measurement No Yes

Sponsored

Why you must not lazy-load the hero image

Because it degrades LCP (Largest Contentful Paint). With loading="lazy" on the main image at the top of the page, the browser will not start fetching it until layout is settled. The largest element paints hundreds of milliseconds—or seconds—later.

For that image, do the opposite and raise its priority:

<!-- main visual at the top of the page -->
<img src="hero.jpg" width="1200" height="630" alt="Main visual"
     fetchpriority="high" decoding="async">

The rule is simple:

  • Visible without scrolling → no loading attribute, and fetchpriority="high" if it is the LCP element
  • Only visible after scrolling → loading="lazy"

Plugin settings that add loading="lazy" to every image in bulk will hurt you here. Check whether yours can exclude the first image.

Lazy-loading CSS background images with Intersection Observer

Native lazy loading does not apply to background-image. This is Intersection Observer’s main territory.

<section class="hero-bg" data-bg="/images/scenery.jpg">
  <h2>Heading</h2>
</section>
.hero-bg {
  min-height: 320px;
  background-color: #eee;   /* placeholder before load */
  background-size: cover;
  background-position: center;
}
const targets = document.querySelectorAll('[data-bg]');

const observer = new IntersectionObserver(
  (entries, obs) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;

      const el = entry.target;
      el.style.backgroundImage = `url("${el.dataset.bg}")`;
      el.removeAttribute('data-bg');
      obs.unobserve(el);
    }
  },
  { rootMargin: '200px' } // start fetching 200px before it enters
);

targets.forEach((el) => observer.observe(el));

rootMargin: '200px' is the practical trick. Starting the fetch the moment the element enters the viewport makes it look like it arrived late. Starting slightly earlier makes it feel like it was always there.

Sponsored

Is the data-src pattern still worth using?

For <img>, no. The old approach—putting the real URL in data-src and moving it to src when the element enters view—now only carries downsides:

  • If JavaScript fails, no images render at all
  • The browser’s preload scanner cannot see the images, so priority hints do not work
  • srcset and sizes get awkward to manage

If all you want is a fade-in, use the load event instead of data-src.

document.querySelectorAll('img[loading="lazy"]').forEach((img) => {
  if (img.complete) {
    img.classList.add('is-loaded');
    return;
  }
  img.addEventListener('load', () => img.classList.add('is-loaded'), { once: true });
});
img[loading="lazy"] {
  opacity: 0;
  transition: opacity .4s;
}
img[loading="lazy"].is-loaded {
  opacity: 1;
}

With this, a total JavaScript failure degrades to “no fade” rather than “no images.”

Deferring heavy embeds: maps, video, ads

Perceived speed is usually hurt more by third-party embeds than by images. A YouTube or Google Maps iframe drags hundreds of kilobytes of script with it.

loading="lazy" helps, but replacing the embed with a placeholder until it is needed is much lighter.

const embed = document.querySelector('#map-placeholder');

const observer = new IntersectionObserver((entries, obs) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;

    const iframe = document.createElement('iframe');
    iframe.src = entry.target.dataset.src;
    iframe.width = '100%';
    iframe.height = '400';
    iframe.loading = 'lazy';
    iframe.title = 'Store location map';
    entry.target.replaceWith(iframe);

    obs.disconnect();
  }
}, { rootMargin: '400px' });

observer.observe(embed);

Always set title. An iframe with no accessible name gives screen reader users nothing to identify it by.

Reducing render cost on long lists

Lazy loading cuts network cost, but the cost of having many DOM nodes stays. CSS content-visibility addresses that.

.card {
  content-visibility: auto;
  contain-intrinsic-size: auto 240px;
}

Off-screen elements skip rendering. Always pair it with contain-intrinsic-size, or the scrollbar will jump around.

Summary

  • loading="lazy" is enough for <img> and <iframe>. No JavaScript required
  • Pair it with width, height, and decoding="async" to prevent CLS
  • Never lazy-load the hero image. Use fetchpriority="high" instead
  • Intersection Observer is for background images, video, heavy embeds, effects, and measurement
  • A rootMargin of 200–400px makes lazy loading imperceptible
  • The data-src pattern is obsolete for <img>; it only adds failure modes
  • For DOM weight itself, use content-visibility: auto

Native attribute first, Intersection Observer only where it cannot reach. That order keeps the code minimal.

Related: controlling image size with object-fit and recent CSS Grid improvements.