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
widthandheightso 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
loadingattribute, andfetchpriority="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
srcsetandsizesget 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, anddecoding="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
rootMarginof 200–400px makes lazy loading imperceptible - The
data-srcpattern 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.