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.
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
<!-- 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:
<!-- 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>
// 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.
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.
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"anddata-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
widthandheight. 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.
Related
- Building a Lazy Image Loader with IntersectionObserver — the observer approach in full
- Lazy Loading Responsive Images with srcset — deferring srcset
- Measuring LCP with PerformanceObserver — checking the hero is not delayed
↑ Back to Lazy Loading Images & Media