Picovert

Image Lazy Loading: Native, Intersection Observer, and LCP Traps

2026-04-227 min read

Image lazy loading defers fetching images until they're close to the viewport. Done correctly, it cuts initial page weight by 40–70% on image-heavy pages. Done wrong — most commonly by lazy-loading the LCP image — it adds 500+ ms to your most important performance metric. Here's the complete guide.

Native lazy loading

The simplest implementation is the loading="lazy" HTML attribute:

<img src="photo.webp" loading="lazy" width="800" height="600" alt="..." />

Browser support is now 96%+ for loading="lazy". Browsers that don't support it simply ignore the attribute and load the image eagerly — there's no JavaScript fallback needed.

Native lazy loading uses a distance-from-viewport threshold to determine when to start loading. Chrome starts loading images approximately 1250px below the viewport on fast connections, and 2500px on slow connections, to account for scroll speed.

The LCP trap: never lazy-load your hero image

The most common lazy loading mistake is adding loading="lazy" to the first visible image on the page — often the hero or banner image, which is the LCP candidate.

When the LCP image is lazy-loaded, the browser delays its request until the lazy loading trigger fires (a scroll event or IntersectionObserver notification). Since the image is in the viewport on load, this trigger fires immediately — but the delay between page load and the trigger can add 300–600 ms to LCP.

Fix: never add loading="lazy" to above-the-fold images. Instead, add fetchpriority="high" to signal the browser to prioritize this image.

Combining lazy loading with WebP

Lazy loading reduces the number of images that need to be fetched on initial load. Converting those images to WebP reduces the size of each fetch. Together, they multiply: a page with 20 below-the-fold images that are 200 KB each (JPEG) and lazy-loaded delivers an initial payload of ~0 KB for those images, then ~100 KB each on demand (WebP). Convert your images at Picovert.

Intersection Observer for custom lazy loading

For cases where native lazy loading isn't sufficient (complex animations, background images, iframes), use the Intersection Observer API:

const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; observer.unobserve(img); } }); }); document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));

Use rootMargin: '200px' to start loading 200px before the image enters the viewport, preventing visible loading flashes on fast scrolls.

Background images

CSS background images aren't lazy-loaded by native loading="lazy". To lazy-load a CSS background image, use Intersection Observer to add a class that triggers the background:

// Start with no background .hero { /* empty */ } // Add class when in viewport .hero.loaded { background-image: url('/hero.webp'); }

Lazy loading in Next.js

Next.js's next/image component applies loading="lazy" by default to all images. To disable it for the LCP image, add priority:

<Image src='/hero.webp' priority width={1200} height={630} alt='Hero' />

The priority prop sets loading="eager", adds a preload link, and sets fetchpriority="high" — all three signals together for maximum LCP priority.

Common mistakes checklist

  • Lazy-loading above-the-fold images. Any image visible on load should be eager.
  • Missing width and height on lazy-loaded images. Without dimensions, the space isn't reserved and you get CLS as images load.
  • Using lazy loading as a substitute for compression. A 2 MB WebP lazy-loaded is still 2 MB when it loads. Compress first.
  • Lazy-loading images in carousels that are immediately visible. The first slide is always in viewport — don't lazy-load it.