Blog Performance 9 min read

Lazy Loading Images: native loading=lazy vs Placeholder Fallbacks

Compare native lazy loading images with placeholder fallback patterns, learn when each is appropriate, and prevent layout shift by combining both in production.

Lazy loading imagesloading=lazyImage placeholdersCore Web VitalsLayout shift
Lazy Loading Images: native loading=lazy vs Placeholder Fallbacks

Native lazy loading images with loading=lazy is one line of HTML but it defers the network fetch without providing any visible state during the gap. Combining lazy loading with a low-cost placeholder ensures the layout stays stable and users have a visual signal that content is coming.

This guide covers when to use loading=lazy, when to use loading=eager, how skeleton and blur placeholders interact with the lazy loading lifecycle, and the specific patterns that prevent CLS in image-heavy pages.

Basics

What native loading=lazy actually does for lazy loading images

The loading attribute with value lazy tells the browser to defer loading the image until it approaches the viewport. The distance threshold varies by browser—Chrome uses roughly 1250px below the viewport at a fast connection, more at slow connections. Firefox uses a similar heuristic. Safari supported lazy loading from version 15.4.

During the deferral period, if the img element has no placeholder src and no background, the space is empty or collapsed. Whether it collapses depends on whether you set width and height attributes. With attributes set, the browser reserves the space; without them, the space collapses and reflows when the image loads.

Loading lazy does not eliminate broken images. If the deferred URL returns a 404 or fails to load, the browser still shows the broken-image icon. Combining loading=lazy with an onerror fallback covers both the deferred case and the failure case.

Placeholder gap

Filling the visual gap during lazy loading

The period between page render and when the image loads creates a gap. On a fast connection, this gap is barely noticeable. On a 3G connection or when the page has many below-the-fold images, the gap can last several seconds. Users scrolling through a long feed see gray or empty slots instead of content.

A placeholder fills the gap with a visible, sized element that signals image content is coming. The placeholder can be a static colored rectangle, a skeleton animation, or a blur-up low-quality version. The choice affects both perceived performance and CPU cost.

Static placeholder

A fixed-color rectangle at the correct dimensions. Zero CPU cost, no animation, works well for grids where the gap is brief.

Skeleton animation

A shimmer animation that indicates loading state. Higher perceived performance than static but costs CSS animation frames.

Blur-up placeholder

A blurred low-quality version of the image, fading to the real image. Best perceived performance but requires a separate low-res image URL.

Implementation

Using a static placeholder with loading=lazy

The simplest pattern: set src to the placeholder URL and data-src to the real image URL. A small JavaScript observer swaps data-src to src when the element enters the viewport. This predates native lazy loading and is still useful when you want control over the threshold or need to support older browsers.

With native lazy loading, set src to the placeholder and use a secondary srcset or JS swap on load. Alternatively, use both src (placeholder) and loading=lazy: the browser shows the placeholder immediately and fetches the lazy-loaded image when it nears the viewport, then the load event fires and you can replace the src.

Implementation text
<!-- Static placeholder with native lazy loading -->
<img
  src="https://fallback.pics/api/v1/800x500/F3F4F6/E5E7EB"
  data-src="/product-photo.jpg"
  loading="lazy"
  width="800"
  height="500"
  alt="Product photo"
  class="lazy-image"
/>

<script>
document.addEventListener('DOMContentLoaded', () => {
  const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        img.removeAttribute('data-src');
        observer.unobserve(img);
      }
    });
  }, { rootMargin: '200px' });

  document.querySelectorAll('img[data-src]').forEach((img) => {
    observer.observe(img);
  });
});
</script>

LCP

Do not lazy load above-the-fold images (LCP impact)

The most common lazy loading mistake is applying loading=lazy to the Largest Contentful Paint (LCP) element. The LCP is typically the hero image or the first product image in a catalog. Deferring it delays the LCP metric, which directly affects Core Web Vitals scores and page experience signals.

Use loading=eager on images that are visible in the initial viewport. Use fetchpriority=high on the LCP image to tell the browser to prioritize it in the fetch queue. All images below the fold can safely use loading=lazy.

A practical rule: images in the first viewport row of a grid use loading=eager; all others use loading=lazy. The exact threshold depends on your grid and viewport size, but erring on the side of eager loading for the first row prevents accidental LCP degradation.

Implementation text
<!-- Hero or first-in-grid: eager and high priority -->
<img
  src="/hero.jpg"
  onerror="this.onerror=null;this.src='https://fallback.pics/api/v1/1200x600/7C3AED/FFFFFF?text=Hero'"
  loading="eager"
  fetchpriority="high"
  width="1200"
  height="600"
  alt="Hero"
/>

<!-- Below-the-fold product grid: lazy with placeholder -->
<img
  src="https://fallback.pics/api/v1/400x400/F3F4F6/9CA3AF"
  data-src="/product.jpg"
  loading="lazy"
  width="400"
  height="400"
  alt="Product"
  class="lazy-image"
/>

Animated skeleton

Using animated skeleton placeholders during lazy loading

Animated skeleton placeholders communicate loading state better than static gray boxes. The shimmer animation gives users a clear signal that content is being fetched, not missing. Skeletons are especially effective in social feed layouts where users expect content to flow in progressively.

The fallback.pics animated skeleton route generates a shimmer placeholder from a URL. This avoids writing CSS animations for each image size. Use it as the initial src before swapping to the real image URL on load.

Implementation text
<img
  src="https://fallback.pics/api/v1/animated/skeleton/400x300"
  data-src="/product.jpg"
  loading="lazy"
  width="400"
  height="300"
  alt="Product photo"
  class="lazy-image"
/>

Key takeaways

What to standardize before shipping

  • Set width and height attributes on every lazy-loaded image to prevent layout shift during the deferral period.
  • Never apply loading=lazy to above-the-fold or LCP images—it directly delays the Core Web Vitals LCP score.
  • Use a static placeholder src that matches the slot dimensions to fill the visual gap during lazy loading.
  • Use fetchpriority=high on the LCP image to prioritize it in the browser's fetch queue.
  • Combine loading=lazy with an onerror fallback so deferred images that fail still show a usable placeholder.

Production fallback layer

Use fallback.pics anywhere an image URL is accepted.

Start with one deterministic URL and standardize fallback behavior across your design system.

Read the docs