Lazy Loading Images: When Native Is Enough and When It Isn’t

Frontend Performance Series — practical guides on making the browser do less work

One HTML attribute — loading="lazy" — defers every offscreen image automatically, with no JavaScript, no library, and 95% browser coverage in 2026. But native lazy loading has real limitations. This guide covers both: when the attribute is all you need, and when Intersection Observer gives you the control that native can’t.

Lazy Loading Image

⚡ Quick Answer

Add loading="lazy" to any <img> below the fold. That’s it for 95% of use cases. Use Intersection Observer when you need custom placeholders, fade-in animations, lazy loading CSS background images, or fine-grained control over when loading triggers. Never lazy load the LCP image — it adds 200–500ms to your largest contentful paint and directly hurts your Core Web Vitals score.


📋 Table of Contents

  1. Why Lazy Loading Matters
  2. Native Lazy Loading — The One-Attribute Solution
  3. Browser Support in 2026
  4. The LCP Mistake That Kills Core Web Vitals
  5. What Native Lazy Loading Cannot Do
  6. Intersection Observer — Full Control
  7. The Blur-Up Placeholder Technique
  8. Lazy Loading CSS Background Images
  9. Live Demo
  10. Native vs Intersection Observer — How to Choose
  11. The Lazy Loading Checklist
  12. FAQ

Why Lazy Loading Matters

Google’s profiling found that 30–50% of bandwidth on image-heavy pages goes to below-the-fold images — images the user may never even scroll to. Every one of those images is downloaded, decoded, and rendered on page load whether it’s ever seen or not.

Lazy loading fixes this by deferring image loading until the user is about to need them. The page loads faster, uses less bandwidth, and scores better on Core Web Vitals — specifically Largest Contentful Paint and Total Blocking Time.

🧒 Explain it like I’m new to this

Imagine ordering a 10-course meal and having all 10 plates arrive at the same time. Your table is covered, the first course gets cold while you’re trying to eat it, and plates 6–10 just sit there getting worse. Lazy loading is telling the kitchen: bring each course when I’m ready for it, not all at once. The first course arrives fast, hot, and right when you need it.


Native Lazy Loading — The One-Attribute Solution

The simplest form of lazy loading requires exactly one HTML attribute. The browser handles the rest — no JavaScript, no library, no configuration.

<!-- Without lazy loading: browser fetches this immediately on page load -->
<img src="hero.jpg" alt="Hero image">

<!-- With lazy loading: browser waits until the user scrolls near this -->
<img src="product.jpg" alt="Product photo" loading="lazy">

<!-- Always include width and height — critical for preventing layout shift -->
<img
  src="product.jpg"
  alt="Product photo"
  width="800"
  height="600"
  loading="lazy"
>
⚠️
Always include width and height attributes. Without them, the browser doesn’t know how much space to reserve for the image before it loads. When it loads, the page jumps — that’s Cumulative Layout Shift (CLS), and it directly harms your Core Web Vitals score. Dimensions let the browser reserve the exact space upfront.
What does loading=”eager” do?
loading="eager" is the default browser behaviour — fetch the image immediately regardless of scroll position. You only need to write it explicitly when you want to opt out of a global lazy loading rule applied elsewhere. Otherwise, simply omitting the loading attribute has the same effect.

Browser Support in 2026

Native loading="lazy" has excellent coverage across all modern browsers. In browsers that don’t support it, the attribute is silently ignored — images load normally with no errors or broken behaviour.

Browserloading=”lazy” on imgloading=”lazy” on iframe
Chrome / Edge✓ Chrome 77+ / Edge 79+✓ Chrome 77+
Firefox✓ Firefox 75+✓ Firefox 121+
Safari✓ Safari 15.4+⚠ Partial
Global coverage~95% as of 2026~88%
Older browsers (ignore)Images load normallyImages load normally
💡
The 5% of browsers that don’t support loading="lazy" simply load images the normal way — nothing breaks, you just lose the performance benefit for those users. No polyfill is needed for production use. The graceful degradation is exactly what you want: the feature is additive, not breaking.

The LCP Mistake That Kills Core Web Vitals

This is the single most common lazy loading error — and Lighthouse flags it explicitly. Never add loading="lazy" to your LCP element: the largest image visible above the fold without scrolling, typically the hero image or the main product photo.

❌ Don’t — lazy loading the hero
The hero image is the LCP candidate. Adding loading="lazy" tells the browser to delay fetching it until it determines it’s in the viewport — which adds 200–500ms to LCP. Lighthouse will flag this directly.

<img src="hero.jpg" alt="Hero" loading="lazy">
✅ Do — prioritise the hero instead
The LCP image should load as fast as possible. Use fetchpriority="high" to explicitly tell the browser this image is critical. Lazy load everything below the fold.

<img src="hero.jpg" alt="Hero" fetchpriority="high">
⚠️
Rule of thumb: any image visible in the first ~800px of the page without scrolling should not be lazy loaded. Use fetchpriority="high" on the LCP image, and lazy load everything below it. If you’re unsure which image is your LCP, run Lighthouse — it highlights the LCP element directly.

What Native Lazy Loading Cannot Do

Native loading="lazy" is excellent for the common case. But there are four things it genuinely cannot do — and these are exactly the cases where Intersection Observer earns its place.

  • Custom placeholders. Native lazy loading shows a blank box until the image loads. You can’t show a blurred preview, a dominant colour, or a skeleton with the native attribute alone.
  • Fade-in animations. You can’t apply a CSS transition that fades an image in smoothly when it arrives. Native loading gives no JavaScript event at the moment loading starts.
  • CSS background images. loading="lazy" only works on <img> and <iframe> elements. CSS background-image properties cannot use it at all.
  • Custom threshold control. The browser decides when “near the viewport” means it should start loading — you can’t configure this distance. Intersection Observer lets you set a precise rootMargin.

Intersection Observer — Full Control

Intersection Observer is the JavaScript API that native lazy loading is built on internally. Using it directly gives you control over every aspect of the loading behaviour: when loading starts, what shows as a placeholder, and what happens when the image arrives.

The pattern: store the real image URL in a data-src attribute instead of src. The browser won’t fetch an image with no src. When the observer fires, swap data-src into src, and the browser fetches it.

<!-- data-src holds the real URL. src is empty (or a tiny placeholder).
     The browser fetches nothing until JavaScript swaps data-src into src. -->
<img
  data-src="product.jpg"
  src=""
  alt="Product photo"
  width="800"
  height="600"
  class="lazy"
>
const lazyImages = document.querySelectorAll('img.lazy[data-src]');

const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;

      const img = entry.target;

      // Swap data-src into src — browser starts fetching now
      img.src = img.dataset.src;

      // Fade in smoothly once loaded
      img.addEventListener('load', () => img.classList.add('loaded'));

      // Stop watching — this image is done
      observer.unobserve(img);
    }
  },
  {
    // Start loading 300px before the image enters the viewport.
    // Gives the browser a head start so images arrive before the user sees them.
    rootMargin: '300px 0px',
    threshold: 0.01
  }
);

lazyImages.forEach(img => observer.observe(img));
img.lazy {
  opacity: 0;
  transition: opacity 0.4s ease;
}
img.lazy.loaded {
  opacity: 1;
}
ℹ️
Why rootMargin: '300px 0px'? Without it, the observer fires exactly when the image enters the viewport — which can mean a brief blank flash while the browser fetches and decodes the image. A 300px margin gives the browser a head start, so images are fully loaded by the time the user actually sees them. Start at 200–400px and adjust based on your connection speed targets.

The Blur-Up Placeholder Technique

The blur-up technique shows a tiny, heavily blurred version of the image immediately — giving users a sense of the image’s colour and shape while the full version loads in the background. When the full image arrives, it fades in and replaces the placeholder. Made famous by Medium and used by Gatsby’s image component.

<div class="img-wrapper">
  <!-- Tiny blurred placeholder: ~20x20px, a few hundred bytes, loaded immediately -->
  <img
    class="placeholder"
    src="product-tiny.jpg"
    alt=""
    aria-hidden="true"
  >
  <!-- Full-resolution image: loaded by Intersection Observer -->
  <img
    class="full lazy"
    data-src="product.jpg"
    src=""
    alt="Product photo"
    width="800"
    height="600"
  >
</div>
.img-wrapper {
  position: relative;
  overflow: hidden;
  background: #e8e8e8; /* fallback while nothing has loaded */
}

/* The tiny placeholder — stretched and blurred to fill the container */
.img-wrapper .placeholder {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  filter: blur(12px);
  transform: scale(1.05); /* hides the blurred edges */
  transition: opacity 0.4s;
}

/* Full image fades in on top */
.img-wrapper .full {
  position: relative;
  width: 100%; height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 0.5s ease;
}

/* When the full image has loaded: show it, hide the blur */
.img-wrapper .full.loaded { opacity: 1; }
.img-wrapper .full.loaded + .placeholder, /* sibling ordering varies */
.img-wrapper:has(.full.loaded) .placeholder { opacity: 0; }
💡
How small should the placeholder be? 20×20px is the sweet spot — small enough to be a few hundred bytes (often under 500 bytes as a JPEG), large enough to show colour and rough shape when blurred. Generate them at build time from the original image: sharp in Node.js handles this in one line — sharp(input).resize(20).jpeg({ quality: 40 }).toFile(output).

Lazy Loading CSS Background Images

The native loading attribute doesn’t work on CSS background-image. To lazy load a background image, use Intersection Observer to watch the container and add a class when it enters the viewport. That class sets the background-image.

<!-- No background-image in CSS yet — added by JS when visible -->
<div
  class="hero-banner lazy-bg"
  data-bg="url('hero-bg.jpg')"
  aria-label="Hero banner"
>
  <h2>Welcome</h2>
</div>
const bgObserver = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;

    const el = entry.target;
    // Apply the background-image stored in data-bg
    el.style.backgroundImage = el.dataset.bg;
    el.classList.add('bg-loaded');

    bgObserver.unobserve(el);
  }
}, { rootMargin: '200px 0px' });

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

Live Demo

Twelve images below — six loaded with native loading="lazy", six loaded with Intersection Observer (with a fade-in on arrival). Open your browser’s Network tab and filter to images, then scroll down to watch requests fire as each image enters the viewport.

Lazy loading demo
Loaded: 0 / 12

native loading=”lazy”

Intersection Observer + fade-in

Open Network tab → filter to Img — watch requests fire as you scroll ↓


Native vs Intersection Observer — How to Choose

SituationWhat to use
Standard below-fold imagesloading="lazy" — one attribute, done
Hero / LCP imagefetchpriority="high" — load it as fast as possible
Above-the-fold imagesOmit loading entirely — default eager behaviour
Fade-in animation on loadIntersection Observer + CSS transition
Blur-up placeholderIntersection Observer + tiny placeholder image
CSS background-imageIntersection Observer — native attribute doesn’t apply
Custom load threshold / distanceIntersection Observer with rootMargin
iframes (YouTube, Maps, etc.)loading="lazy" on the <iframe> element

The Lazy Loading Checklist

  • ✅ Add loading="lazy" to every <img> below the fold
  • ✅ Add width and height to every <img> — prevents CLS
  • ✅ Add fetchpriority="high" to the LCP image
  • ❌ Never add loading="lazy" to the LCP element or hero image
  • ❌ Never lazy load images in the first ~800px of the page
  • ✅ Use Intersection Observer for CSS background images
  • ✅ Set rootMargin: '200px–400px' to pre-load before the user scrolls there
  • ✅ Call observer.unobserve(img) after loading — don’t keep watching
  • ✅ Verify with Lighthouse — it flags both missing lazy loading and misapplied lazy loading on LCP
🎛️
Up next in the series
Debounce vs Throttle — The Visual Guide

Scroll and resize handlers fire hundreds of times per second. Debounce and throttle limit that — but they work differently and the wrong choice causes real bugs. Part 3 covers both with a side-by-side interactive demo so you can see the difference, not just read about it. Read Part 3 →


Frequently Asked Questions

What is lazy loading for images?

Lazy loading defers loading offscreen images until the user scrolls near them. Instead of downloading every image on page load, the browser waits until an image is about to enter the viewport before fetching it — reducing initial load time and saving bandwidth.

Should I use loading=”lazy” or Intersection Observer in 2026?

Start with native loading="lazy" for all standard img and iframe elements. It requires one attribute, has no JavaScript overhead, and covers 95% of browsers. Upgrade to Intersection Observer only when you need custom placeholders, fade-in animations, CSS background image lazy loading, or precise control over the loading threshold.

Can you lazy load the LCP image?

No — this is the most common lazy loading mistake. Lazy loading the Largest Contentful Paint element delays it by 200–500ms, directly harming your Core Web Vitals score. Lighthouse flags it explicitly. The LCP image and any image above the fold should use fetchpriority="high" or simply omit the loading attribute.

Does lazy loading work on CSS background images?

No. loading="lazy" only works on <img> and <iframe> elements. To lazy load a CSS background image, use Intersection Observer to watch the container and add a class when it enters the viewport — that class then applies the background-image property.

What is a blur-up placeholder for lazy loading?

A blur-up placeholder is a tiny (20×20px) blurred version of an image shown immediately while the full-resolution image loads. When the full image arrives it fades in, replacing the placeholder. This prevents blank white boxes during loading and gives users a sense of the image content and colour before it fully appears.


What to Take Away

Lazy loading images is one of the lowest-effort, highest-impact performance wins available to any frontend developer. The decision tree is simple:

  • Below-fold <img>: add loading="lazy" and width/height. Done.
  • LCP / hero image: add fetchpriority="high". Never loading="lazy".
  • Need a fade-in or blur-up placeholder: Intersection Observer with a CSS transition.
  • CSS background-image: Intersection Observer — native doesn’t apply here.
  • Custom load threshold: rootMargin: '200px–400px' in your Intersection Observer options.
  • Verify: run Lighthouse after every change — it catches both missing lazy loading and the LCP mistake.

Next up: Part 3 — Debounce vs Throttle → (Coming Soon)


Posted

in

,

by

Advertisement