Lazy Loading Images: Native vs Intersection Observer
Lazy loading defers offscreen images until they near the viewport. Comparing the native loading attribute against Intersection Observer-based approaches.
Lazy loading defers fetching offscreen images until they’re about to enter the viewport, instead of downloading every image on a page up front. On a long page with dozens of images, that can mean the difference between a browser fetching two megabytes before first paint and fetching only what the visitor actually scrolls to see.
The native loading attribute
The simplest way to lazy-load images today is a single HTML attribute:
<img src="photo.jpg" loading="lazy" alt="..." width="800" height="600">
The browser decides when to fetch the image based on its own heuristics — typically when the image is some distance from the viewport, so it’s already loaded by the time the user scrolls to it. This requires no JavaScript, works the same way across modern browsers, and degrades gracefully: browsers that don’t recognize the attribute simply ignore it and load the image eagerly.
The tradeoff is control. You can’t customize the threshold, add a fade-in transition when the image loads, or lazy-load background images set via CSS — loading="lazy" only applies to <img> and <iframe> elements.
Intersection Observer-based lazy loading
Before the native attribute existed, and still today when you need more control, lazy loading is implemented with the IntersectionObserver API. The pattern: store the real image URL in a data-src attribute, watch the element, and swap it into src when it intersects the viewport.
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
}, { rootMargin: "200px" });
document.querySelectorAll("img[data-src]").forEach((img) => observer.observe(img));
This gives you a few things the native attribute doesn’t: a configurable rootMargin so images start loading well before they’re visible, the ability to lazy-load CSS background images by swapping a class, and a hook to add a loading placeholder or fade transition once the image arrives.
The cost is that it’s JavaScript-dependent — if the script fails to load or run, images never appear unless you’ve also set a <noscript> fallback with a plain <img> tag.
Comparison table
Native loading="lazy" | Intersection Observer | |
|---|---|---|
| JavaScript required | No | Yes |
Works on <img>/<iframe> | Yes | Yes |
| Works on CSS background images | No | Yes |
| Custom threshold/margin | No, browser-decided | Yes, fully configurable |
| Fails gracefully without JS | Yes (loads eagerly) | Only with a fallback |
| Effort to implement | One attribute | Requires a script |
What not to lazy-load
Lazy loading the wrong image hurts more than it helps. The image visible in the initial viewport — often called the hero or LCP (Largest Contentful Paint) image — should load eagerly, ideally with fetchpriority="high", since it’s usually the element the Largest Contentful Paint metric is measuring. Lazy-loading that image delays the very thing you’re trying to speed up.
A good rule: eager-load anything above the fold, lazy-load everything below it. Pairing this with preload for critical above-the-fold assets and lazy loading for the rest covers both ends of the tradeoff.
Layout shift and dimensions
Whichever method you use, always specify width and height (or an aspect-ratio in CSS) on lazy-loaded images. Without them, the browser doesn’t know how much space to reserve before the image loads, which causes layout to jump as images pop in — a direct hit to the Cumulative Layout Shift metric covered in the Core Web Vitals guide. This matters more for lazy-loaded images than eager ones, since they load at unpredictable times relative to scrolling.
How rendering strategy interacts with lazy loading
The rendering approach you use for a page — server-rendered, statically generated, or client-rendered — changes how much this matters. A page built with SSG ships fully-formed HTML immediately, so the browser can start evaluating loading="lazy" on real <img> tags right away. A client-rendered page has to wait for JavaScript to build the DOM before any image, lazy or not, even exists to be observed — which is one more reason to prefer server-rendered HTML for image-heavy pages, and to serve images through a CDN so the ones you do fetch arrive quickly.
The takeaway
Native loading="lazy" is the right default for ordinary <img> tags — zero JavaScript, broad support, graceful degradation. Reach for Intersection Observer only when you need custom thresholds, lazy-loaded background images, or a loading transition the native attribute can’t provide. Either way, never lazy-load the image visible on first paint, and always reserve space with explicit dimensions so images don’t cause layout shift when they arrive.
Keep reading
Takina · · 4 min read What Is the Beacon API? navigator.sendBeacon()
The Beacon API lets a page send one last async request as it unloads, without blocking navigation or racing the browser's page teardown.
Takina · · 3 min read What Is Fetch Priority? The fetchpriority Attribute
fetchpriority lets you tell the browser which resources matter most, overriding its default heuristics to load critical assets sooner.
Takina · · 5 min read CSS will-change Explained: Compositing and Performance
The CSS will-change property hints the browser to prepare an element for an upcoming change, moving it to its own compositor layer. When to use it and when not to.