Browse by section

Web Design 日本語

Intersection Observer: Lazy-Load Backgrounds and Embeds

For lazy loading, the HTML loading="lazy" attribute is the right first answer in 2026. Every modern browser supports it, and you write zero JavaScript. How to write it, and how to work out why it is not firing, is covered in why loading=”lazy” is not working, and its downsides.

This article deals only with the cases that native attribute cannot reach: CSS background images, heavy iframe embeds (maps, video, ads), custom placeholder animations, and scroll-based measurement. Those four are the Intersection Observer API’s territory, and each one below comes with code you can use as-is.

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.

Where the native attribute does not reach

The short version: for <img> and <iframe>, loading="lazy" finishes the job; everything else needs Intersection Observer. Decide which column you are in before you start writing.

Target loading="lazy" Intersection Observer
<img> / <iframe> Sufficient Not needed
CSS background image Not supported Swap a class or style
<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

The first row is out of scope here. Pairing the attribute with width, height and decoding="async", why you must never put it on an above-the-fold image (it damages LCP), and how fetchpriority differs are all in why loading=”lazy” is not working, and its downsides. What follows works through the remaining rows in order.

Sponsored

Lazy-loading CSS background images with Intersection Observer

Native lazy loading does not apply to background-image—there is no element to put the attribute on. 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;
  transition: opacity .4s;
}
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. loading="lazy" gives you no way to set this distance, so wanting control over how far ahead to pre-fetch is itself a reason to come here.

Custom placeholders and fade-in effects

For <img>, you no longer need the data-src pattern at all. The old approach—putting the real URL in data-src and moving it to src when the element enters view—now that loading="lazy" is universally available, carries nothing but 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 soft fade from a placeholder, you need neither data-src nor Intersection Observer. Leave loading to the browser and add a class on the load event.

img[loading="lazy"] {
  background-color: #f2f2f2; /* placeholder before load */
  opacity: 0;
  transition: opacity .4s;
}
img[loading="lazy"].is-loaded {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  img[loading="lazy"] { opacity: 1; transition: none; }
}
document.querySelectorAll('img[loading="lazy"]').forEach((img) => {
  if (img.complete) {
    img.classList.add('is-loaded'); // catch cached images
    return;
  }
  img.addEventListener('load', () => img.classList.add('is-loaded'), { once: true });
});

The img.complete check is required. For images served from cache, load may have already fired before your script runs. Skip it and some images stay permanently transparent—an unpleasant bug to track down.

Written this way, a total JavaScript failure degrades to “no fade” rather than “no images,” because the browser still loads them. Do not leave out prefers-reduced-motion: reduce either: where someone has asked for less motion, the fade itself is a burden.

Sponsored

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.

Video follows the same thinking. <video> has no loading attribute, so stopping the file itself from downloading (preload="none" plus poster) belongs with the attribute-side article. Reach for Intersection Observer here only when you actually want playback to start once the element is on screen.

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 so the browser has an approximate height, or the scrollbar will jump around.

Appending content as the reader goes is covered in building infinite scroll with the Intersection Observer API, and animating elements as they appear in scroll animations with the Intersection Observer API. Both are the same API applied differently.

Summary

  • <img> and <iframe> need only loading="lazy"; Intersection Observer is unnecessary (details in why loading=”lazy” is not working, and its downsides)
  • Intersection Observer is for background images, heavy embeds (maps, video, ads), custom effects, and measurement
  • Lazy-load a CSS background image by moving data-bg into a class or inline style
  • A rootMargin of 200–400px pre-fetches far enough that nobody notices the deferral
  • The data-src pattern is obsolete for <img>; all it leaves is the risk of losing every image when JavaScript fails
  • A fade needs only the load event plus an img.complete check—and write prefers-reduced-motion too
  • When you create an iframe dynamically, always give it a title
  • For DOM weight itself, use content-visibility: auto

Native attribute first, Intersection Observer only where it cannot reach. That order keeps the code you write to a minimum.

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