Articles

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.

Takina Takina · · 3 min read
A dashboard showing web performance metrics

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 requiredNoYes
Works on <img>/<iframe>YesYes
Works on CSS background imagesNoYes
Custom threshold/marginNo, browser-decidedYes, fully configurable
Fails gracefully without JSYes (loads eagerly)Only with a fallback
Effort to implementOne attributeRequires 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.

Takina 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.

#Web Development #Frontend #Performance
Takina 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.

#CSS #Web Development #Performance