Use native loading="lazy" for ordinary images and iframes — it works without JavaScript, from the first byte of HTML — and reach for IntersectionObserver only when you need something native loading cannot do: a custom load distance, placeholder transitions, background images, components, analytics, or coordinating loads with other work.

Problem / Scenario Context

A team maintains a custom lazy loader built on IntersectionObserver, written years ago when native lazy loading was not widely supported. A performance audit asks why the site ships 3 KB of lazy-loading JavaScript when the browser does it natively. A senior engineer objects: the custom loader supports blur-up placeholders, loads the product-gallery images one screen early, and reports image load times to analytics. Both are right about something.

The honest answer is "use both, for different images". The Lazy Loading Images & Media topic covers the observer-based loader; this page compares it with the native attribute.

Mechanics Explanation

Native lazy loading. With loading="lazy" on an img or iframe, the browser defers fetching until the element is within an internal distance threshold of the viewport. The threshold is chosen by the browser — Chromium uses roughly 1250–2500 px depending on connection speed; Firefox and Safari use their own values — and is not configurable. The fetch then proceeds with normal priority. Because it is part of HTML parsing, it works before any script runs and with scripting disabled.

IntersectionObserver lazy loading. Images start with no src (or a placeholder) and a data-src. An observer with a chosen rootMargin sets the real src when the image approaches. Everything about timing and behaviour is under your control, and nothing happens until the script has loaded and run — which on a slow page can be seconds after the HTML arrived.

The key trade-off is start time vs control. Native loading can begin as soon as the parser sees the element; observer loading cannot begin before the script executes. For above-the-fold content that matters enormously; for content far below the fold, the script has usually run long before the user gets there.

When Each Approach Can Start LoadingA timeline of page load on a slow connection. The HTML arrives early and native lazy images near the first screen can start loading as soon as they are parsed. The lazy-loading script arrives and executes later; only then can observer-driven images start loading. For images several screens down, both approaches start when the user scrolls near them, long after the script is ready.Slow 3G, product pagedocumentHTML parsedloader JS runsnative lazynear-fold images loadobserver lazynear-fold images loadfar below foldboth: on scroll0s1s2s3s4s5s6s

Comparison Table: Feature by Feature

Capability loading="lazy" IntersectionObserver loader
Works without JavaScript yes no (needs noscript fallback)
Starts before scripts run yes no
Load distance browser-chosen, not configurable any rootMargin
Scroll containers follows the viewport and scrollers any root
Background images no yes
Components, embeds, data iframes only anything
Placeholder / blur-up transitions CSS only, no load event hook in markup full control
Priority coordination fetchpriority attribute full control, can pause/cancel
Analytics hooks load events any
Code to ship none a few KB

Minimal Reproducible Example

HTML
<!-- Custom loader everywhere, including the hero: the LCP image waits for the script. -->
<img class="lazy" data-src="/img/hero.avif" alt="Hero product shot" width="1600" height="900">

On a slow connection, Lighthouse reports the hero image as the LCP element with a long "resource load delay": the browser discovered the real URL only after the loader script ran.

Production-Safe Solution

Choose per image category, with clear rules:

HTML
<!-- 1. Above the fold / LCP candidate: eager, high priority, no laziness at all. -->
<img src="/img/hero.avif" alt="Hero product shot" width="1600" height="900"
     fetchpriority="high" decoding="async">

<!-- 2. Ordinary content images below the fold: native lazy, zero JS. -->
<img src="/img/detail-2.avif" alt="Stitching detail" width="800" height="600"
     loading="lazy" decoding="async">

<!-- 3. Gallery that needs blur-up and a custom distance: observer, with noscript fallback. -->
<img class="lazy-blur" data-src="/img/gallery-7.avif" alt="Colour option: moss"
     width="800" height="800" style="background-image:url(data:image/webp;base64,UklGR…)">
<noscript><img src="/img/gallery-7.avif" alt="Colour option: moss" width="800" height="800"></noscript>
TypeScript
// Only the images that need observer features use it.
const gallery = new IntersectionObserver((entries, obs) => {
  for (const e of entries) {
    if (!e.isIntersecting) continue;
    const img = e.target as HTMLImageElement;
    img.src = img.dataset.src!;
    img.decode().then(() => img.classList.add('sharp')).catch(() => {});
    obs.unobserve(img);
  }
}, { rootMargin: '100% 0px' });           // one full screen ahead
document.querySelectorAll('img.lazy-blur').forEach((img) => gallery.observe(img));

The loader shrinks to the handful of images that need it, the hero is discovered by the preload scanner, and every other image lazy-loads with no script at all. Accessibility details — alt text and dimensions present from the start — apply to both; see accessible lazy images.

Which Loading Strategy for This Image?A decision chain. If the image is above the fold or a likely LCP element, load it eagerly with high fetch priority. Otherwise, if it is an ordinary content image or iframe, use native loading lazy. Otherwise, if it needs a custom distance, placeholder transition, scroll-container root or analytics hook, use an IntersectionObserver loader with a noscript fallback. Otherwise, for background images and components, IntersectionObserver is the only option.Above the fold or a likely LCP element?Eager, fetchpriority="high" — never lazyyesnoOrdinary content image or iframe?loading="lazy" — no JavaScriptyesnoNeeds custom distance, blur-up or analytics?IntersectionObserver + noscript fallbackyesnoBackground images and components: IntersectionObserver is the only option.

Mixing Them Without Double-Loading

The one thing to avoid is putting both on the same element. An image with loading="lazy" and a real src loads natively regardless of any observer; an image with data-src and no src ignores loading="lazy" because there is nothing to fetch yet. Pick one per element, and make it visible in the markup so reviewers can tell which is which — for example, only images with a lazy-* class are touched by script.

There is also a hybrid for iframes: a lightweight facade (thumbnail and play button) that becomes a real iframe loading="lazy" on click or on visibility, which combines the zero-cost initial render of a facade with native deferral once the iframe exists. The YouTube facade guide walks through it.

JavaScript Shipped for Lazy Loading, Before and AfterA bar chart of lazy-loading JavaScript on a product page. Using the custom observer loader for every image required about three kilobytes plus the placeholder logic and delayed the hero image. Using native lazy loading for ordinary images and the observer only for the gallery reduced it to under one kilobyte, loaded after the page became interactive.Product page, compressed JS for image loadingobserver for every image~3.1 KB, blocks hero discoverynative + observer for gallery~0.8 KB, deferred

Verification Steps

  • Run Lighthouse and confirm the LCP image has no "resource load delay" from lazy loading.
  • Disable JavaScript and confirm every content image still appears (native or noscript).
  • Scroll a long page on a throttled connection and confirm native lazy images arrive in time.
  • Check the Network panel to confirm no image loads twice.
  • Audit the markup for elements carrying both loading="lazy" and data-src.

Common Mistakes to Avoid

  • Lazy-loading the hero. It delays LCP whichever mechanism you use.
  • Shipping a custom loader for plain images. Native lazy loading does the same with no code.
  • Omitting width and height. Both approaches cause layout shift without reserved space.
  • Combining both on one element. Behaviour becomes confusing and one is always ignored.

FAQ

Can I change the distance at which native lazy images load?

No. The threshold is chosen by the browser and varies by engine and connection type. If you need a specific distance, use an IntersectionObserver with the rootMargin you want.

Does loading="lazy" work for background images?

No. It applies only to img and iframe elements. CSS background images need an observer that adds a class or sets the property when the element approaches.

Is native lazy loading supported everywhere?

For images it is supported in all current browsers. Unsupporting browsers simply ignore the attribute and load images eagerly, which is a safe fallback.

Do search engines index observer-loaded images?

Major crawlers render pages and scroll or resize the viewport, so observer-loaded images are usually discovered, but native lazy images with real src attributes are indexed most reliably. A noscript fallback helps observer-based images.

Should the first few below-the-fold images be lazy?

Images within roughly the first screen and a half are usually better loaded eagerly at default priority. Lazy loading them saves little and risks visible pop-in on fast scrolls.

How do I measure whether lazy loading is helping?

Compare total image bytes on initial load, LCP and the time images take to appear as the user scrolls, before and after. Native lazy loading usually wins on the first two; an observer with a larger margin can win on the third for galleries.


↑ Back to Lazy Loading Images & Media