Home / Blog / Lazy loading: when native beats IntersectionObserver, and when it doesn’t

Lazy loading: when native beats IntersectionObserver, and when it doesn’t

Is native loading=lazy enough in modern browsers, or do you still need a custom IntersectionObserver implementation? A practical comparison from three projects.

Five years ago lazy loading meant “I need to write custom JavaScript”. Today it’s a one-line loading="lazy" attribute. But I still see custom IntersectionObserver-based lazy loading on most projects. Is it actually necessary?

Here’s my side-by-side from three different projects.

Native loading=”lazy”: what it gives you

Since Chrome 77, Firefox 75, and Safari 15.4, <img loading="lazy"> is native. As of 2026 browser support is around 95% or more.

Usage:

<img src="..." loading="lazy" alt="...">
<iframe src="..." loading="lazy"></iframe>

The browser holds off on fetching the resource until it’s near the viewport. If the image is below the fold, no HTTP request goes out at all.

Native advantages

Zero JavaScript. One attribute. Your JS bundle doesn’t grow.

Browser-level optimization. The browser uses its own priority signals (connection speed, data saver mode, viewport distance). You can’t reach those heuristics from JavaScript.

Automatic decode optimization. The browser decodes off the main thread, no blocking.

No performance overhead. Running a custom IntersectionObserver over a hundred elements burns CPU. Native doesn’t.

Native limitations

Threshold isn’t configurable. The browser decides “how close is close enough”. Chrome typically loads 1250px before the viewport (2500px on mobile). Sometimes too early, sometimes too late.

Only img and iframe. Div background images, video posters, style.backgroundImage: not lazy. Custom elements can’t opt in.

Prerender and prefetch interaction is fiddly. Speculative parsing can behave oddly at the edges.

No callback. There’s no “it just loaded” event aside from attaching a separate load listener. Fade-in animations need extra work.

IntersectionObserver: when you still need it

Custom implementation still earns its keep in a few cases:

Div background images. No native support.

const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            const div = entry.target;
            div.style.backgroundImage = `url(${div.dataset.bg})`;
            observer.unobserve(div);
        }
    });
}, {rootMargin: '50px'});

document.querySelectorAll('[data-bg]').forEach(el => observer.observe(el));

Video autoplay near viewport. <video> poster lazy loading is native, but if you want autoplay to kick in as the element nears the viewport, that’s JS.

Lazy component render. A React component below the fold that shouldn’t render until it’s close? Native doesn’t solve that. IntersectionObserver with lazy() does.

Analytics on viewport. “Was this banner actually seen?” attribution is already IntersectionObserver territory, so folding lazy loading into it is easy.

Custom threshold. “Load when the viewport is 200px away” needs finer control than native offers. IntersectionObserver’s rootMargin is exact.

Intersection animation. Triggering a fade-in when the image loads is cleanest when IntersectionObserver and the load listener live together in one place.

Three projects, side by side

Project 1: E-commerce product grid. 200 product cards, one image each. Native loading="lazy" was enough. Custom JS just added bundle weight. Choice: native.

Project 2: Portfolio gallery. High-res imagery in a lightbox, sections with div background images. Native plus IntersectionObserver hybrid. Native on imgs, IO for backgrounds.

Project 3: Blog. Long-form articles with few images. Didn’t even need native; pages were light, eager loading gave better LCP.

There’s no single answer per project. Context matters.

Hybrid pattern

Native and custom together works well like this:

<!-- Native lazy for images -->
<img src="..." loading="lazy" class="lazy-fade" alt="...">

<!-- Custom for background images -->
<div data-bg="/path/to/bg.jpg" class="lazy-bg"></div>

The JS side:

// Fade-in for natively lazy images
document.querySelectorAll('.lazy-fade').forEach(img => {
    if (img.complete) {
        img.classList.add('loaded');
    } else {
        img.addEventListener('load', () => img.classList.add('loaded'));
    }
});

// IntersectionObserver for background images
const bgObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            const el = entry.target;
            el.style.backgroundImage = `url(${el.dataset.bg})`;
            el.classList.add('loaded');
            bgObserver.unobserve(el);
        }
    });
}, {rootMargin: '100px'});

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

About 2 KB of JS, clear net win.

Performance measurement

Ways to measure the impact:

LCP. Confirm your above-the-fold images load fast. The LCP image must not be lazy.

FCP. Should be roughly unchanged.

Total bandwidth. Total transfer size in the DevTools Network panel. Lazy loading widens the gap, and the page stays light without needing a long scroll.

CLS. Without explicit width and height, lazy loading can hurt CLS. Always set placeholder dimensions.

Common mistake: above-the-fold lazy

The mistake I see most: loading="lazy" on the hero image. The browser’s priority heuristics still end up loading it, but the signal is confused, and LCP gets 200 to 400ms worse.

Above-the-fold elements should be eager, ideally with fetchpriority="high".

<!-- LCP image -->
<img src="hero.jpg" fetchpriority="high" alt="...">

<!-- Below fold -->
<img src="gallery-1.jpg" loading="lazy" alt="...">

Fetch priority: a new lever

The fetchpriority attribute (Chrome 101+, Safari 17.2+) is the next step after native lazy. Values: high, low, auto.

  • fetchpriority="high": LCP, above-fold
  • fetchpriority="low": decorative, absolutely not critical
  • fetchpriority="auto": default

The browser uses those signals to reorder requests. Strong lever for LCP improvement.

My decision guide

  1. Small or medium project, only img and iframe lazy needed: native only
  2. Background images or custom elements need lazy: IntersectionObserver
  3. Both: hybrid pattern
  4. Below-fold imagery dominates, above-fold minimal: absolutely lazy load
  5. Above-fold imagery dominates: lazy may not help, go eager with fetchpriority

Closing note

Before 2020, custom JS was mandatory. Today, native covers 80% of cases. I still reach for IntersectionObserver, but only where native isn’t enough.

On a new project I start with the native attribute and add custom JS only when the need shows up. Minimal code, maximum performance.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch