Lazy loading and modern format selection are two independent optimizations that combine multiplicatively. Lazy loading prevents downloading images the user hasn't scrolled to yet. Modern formats (WebP, AVIF) reduce the size of images they do download. Together, a page that loaded 8MB of images can drop to 200KB of initial payload.

This guide covers both techniques with production-ready code. No theory-only explanations — every section includes copy-paste implementations and the CWV impact of each optimization.

Native Lazy Loading: The 90% Solution

The loading="lazy" attribute tells the browser to defer loading an image until it's near the viewport. It's supported in all modern browsers (96%+ global support) and requires zero JavaScript.

<img src="photo.webp" alt="Product shot" width="400" height="300" loading="lazy" decoding="async">

How it works: The browser calculates a threshold distance from the viewport (roughly 1250px on fast connections, 2500px on slow connections). When an image enters that threshold, the browser starts the network request. The thresholds are not configurable — the browser adapts them based on connection speed and data saver settings.

What not to lazy-load:

  • LCP image: Your Largest Contentful Paint image must load eagerly. Lazy loading it delays LCP by the Intersection Observer overhead (~100-300ms) plus the connection setup time. Never lazy-load your hero image, primary product image, or any image that's likely the LCP element.
  • Above-the-fold images: Any image visible without scrolling should load eagerly. Adding loading="lazy" to above-the-fold images hurts rather than helps.

Lazy Loading with the Element

The loading attribute goes on the <img> inside <picture>, not on the <source> elements:

<picture>
  <source srcset="photo.avif" type="image/avif">
  <source srcset="photo.webp" type="image/webp">
  <img src="photo.jpg" alt="Description" width="800" height="600" loading="lazy" decoding="async">
</picture>

The browser evaluates loading="lazy" first (should I load this at all?), then picks the best <source> format (what format should I load?). Both decisions happen before any network request fires.

Intersection Observer: When Native Isn't Enough

Native lazy loading covers most cases, but Intersection Observer gives you finer control: custom thresholds, load callbacks for analytics, and the ability to lazy-load background images and other non-<img> elements.

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      // Move data-src to src to trigger load
      img.src = img.dataset.src;
      if (img.dataset.srcset) img.srcset = img.dataset.srcset;
      img.removeAttribute('data-src');
      observer.unobserve(img);
    }
  });
}, {
  rootMargin: '200px 0px', // Start loading 200px before viewport
  threshold: 0
});

document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));

When to use Intersection Observer over native:

  • Lazy-loading CSS background images (native loading only works on <img>)
  • Custom threshold distance (native doesn't expose this)
  • Load event callbacks (analytics, animation triggers)
  • Progressive enhancement patterns (load low-res first, swap to high-res)

Placeholder Strategies: LQIP and BlurHash

Without a placeholder, lazy-loaded images pop in abruptly when they load, creating a jarring experience. Placeholders fill the space with a preview until the full image arrives.

LQIP (Low-Quality Image Placeholder)

LQIP inlines a tiny version of the image (20-40px wide, heavily compressed) as a base64 data URI. The browser renders it blurred and upscaled while the full image loads.

<!-- LQIP: 20px wide JPG, ~300 bytes base64 -->
<img
  src="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
  data-src="photo-800.webp"
  alt="Landscape"
  width="800"
  height="600"
  style="filter: blur(10px); transition: filter 0.3s;"
  class="lazy"
>

Generate LQIP with sharp:

const lqip = await sharp('photo.jpg')
  .resize(20)  // 20px wide
  .jpeg({ quality: 40 })
  .toBuffer();
const dataUri = `data:image/jpeg;base64,${lqip.toString('base64')}`;
// Inline this in the src attribute

A 20px LQIP is typically 200-400 bytes after base64 encoding. For a page with 20 images, that's 4-8KB of inline placeholders — negligible compared to the deferred image payloads.

BlurHash

BlurHash encodes an image's color structure as a compact string (20-30 characters). A BlurHash decoder renders it to a canvas, producing a colorful blur effect that's more representative than a solid color but smaller than LQIP.

// Generate hash server-side (node)
import { encode } from 'blurhash';
import sharp from 'sharp';

const { data, info } = await sharp('photo.jpg')
  .raw()
  .ensureAlpha()
  .resize(32, 32, { fit: 'inside' })
  .toBuffer({ resolveWithObject: true });

const hash = encode(new Uint8ClampedArray(data), info.width, info.height, 4, 3);
// Result: "LEHV6nWB2yk8pyo0adR*.7kCMdnj" (~28 chars)

// Decode client-side
import { decode } from 'blurhash';
const pixels = decode(hash, 32, 32);
// Render pixels to canvas

BlurHash vs LQIP: BlurHash is smaller (~30 bytes vs ~300 bytes) but requires JavaScript to render. LQIP is larger but works without JS. BlurHash produces smoother gradients; LQIP preserves more structural detail. For SSR sites, LQIP is simpler. For SPAs, BlurHash is elegant.

Preventing Cumulative Layout Shift (CLS)

Lazy-loaded images without explicit dimensions cause layout shifts when they load — the browser doesn't know how much space to reserve. This kills your CLS score.

The fix is simple: Always set width and height attributes on every <img> element.

<!-- Bad: no dimensions, causes CLS -->
<img src="photo.webp" loading="lazy" alt="Photo">

<!-- Good: dimensions set, browser reserves space -->
<img src="photo.webp" loading="lazy" alt="Photo" width="800" height="600">

<!-- Also good: CSS aspect-ratio -->
<img src="photo.webp" loading="lazy" alt="Photo" style="aspect-ratio: 4/3; width: 100%; height: auto;">

The browser calculates the aspect ratio from width/height and reserves the correct vertical space before the image loads. This works even when CSS overrides the display dimensions — the aspect ratio is preserved.

For responsive images: The width and height should reflect the image's intrinsic dimensions (the actual pixel size), not the CSS display size. The browser uses them only for aspect ratio calculation.

Format Selection with the Element

The <picture> element lets you serve different formats based on browser support. The browser evaluates <source> elements top to bottom and picks the first it can decode.

<picture>
  <!-- Best: AVIF, smallest files, 92%+ support -->
  <source srcset="photo-400.avif 400w, photo-800.avif 800w" sizes="(max-width: 768px) 100vw, 50vw" type="image/avif">
  
  <!-- Good: WebP, 97%+ support -->
  <source srcset="photo-400.webp 400w, photo-800.webp 800w" sizes="(max-width: 768px) 100vw, 50vw" type="image/webp">
  
  <!-- Fallback: JPG, universal -->
  <img src="photo-800.jpg" srcset="photo-400.jpg 400w, photo-800.jpg 800w" sizes="(max-width: 768px) 100vw, 50vw" alt="Description" width="800" height="600" loading="lazy" decoding="async">
</picture>

Format selection order matters: Put the most compressed format first (AVIF), then the next best (WebP), then the fallback (JPG). The browser stops at the first compatible format. If you put WebP before AVIF, no browser will ever load the AVIF version.

JavaScript Format Detection

Sometimes you need to know the best format in JavaScript (for canvas operations, dynamic image loading, or analytics). Use feature detection:

async function getBestFormat() {
  // Test AVIF support
  const avif = new Image();
  avif.src = 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAACVtZGF0EgAKBzgABpAQ0AIyExAAAAAP+j9P/HjL8A==';
  const avifSupported = await new Promise(r => { avif.onload = () => r(true); avif.onerror = () => r(false); });
  if (avifSupported) return 'avif';

  // Test WebP support
  const webp = new Image();
  webp.src = 'data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA';
  const webpSupported = await new Promise(r => { webp.onload = () => r(true); webp.onerror = () => r(false); });
  if (webpSupported) return 'webp';

  return 'jpg';
}

fetchpriority for LCP Images

The fetchpriority attribute tells the browser how important an image is relative to other resources. For your LCP image, set it to high:

<!-- Hero image: highest priority, no lazy loading -->
<img src="hero.webp" alt="Hero" width="1200" height="600" fetchpriority="high" decoding="async">

<!-- Below-the-fold images: low priority, lazy loaded -->
<img src="secondary.webp" alt="Feature" width="400" height="300" loading="lazy" fetchpriority="low" decoding="async">

Impact: In Chrome's network prioritization, fetchpriority="high" on an image moves it from "Low" priority (default for images) to "High" priority, putting it ahead of non-critical scripts and stylesheets. Google's own testing shows this reduces LCP by 400-1200ms on image-heavy pages.

Don't overuse it. If you set fetchpriority="high" on every image, you've prioritized nothing. Use it on exactly one image: the LCP element. Everything else should be default or low.

Preloading the LCP Image

Preloading goes further than fetchpriority — it tells the browser to start downloading the image before it even parses the HTML that references it.

<head>
  <!-- Preload with format and responsive variants -->
  <link rel="preload" as="image" type="image/avif"
    imagesrcset="hero-400.avif 400w, hero-800.avif 800w, hero-1200.avif 1200w"
    imagesizes="100vw"
    fetchpriority="high">
</head>

When to preload: Only preload if the LCP image is discovered late — for example, if it's referenced in CSS (background-image), generated by JavaScript, or deep in the DOM. If the image is in a plain <img> tag near the top of the body, the browser's preload scanner already finds it early, and explicit preloading adds minimal benefit.

Preload exactly one format. If you preload both AVIF and WebP, the browser downloads both. Pick the format with the best support-to-compression ratio (WebP in most cases, since it has wider support than AVIF and the preload can't do format negotiation).

Lazy loading and modern format selection are the two highest-ROI image performance optimizations. Native lazy loading requires one HTML attribute. Format selection requires a <picture> element wrapper. Combined with explicit dimensions for CLS prevention and fetchpriority for LCP, you have a complete image loading strategy in about 10 lines of HTML per image.

The advanced techniques (LQIP, BlurHash, Intersection Observer) are worth implementing for image-heavy pages where perceived performance matters — portfolio sites, e-commerce product grids, photo galleries. For content sites with 2-3 images per page, native lazy loading and <picture> are all you need.