Step-by-step
-
1
Add loading="lazy" to below-fold images
This is the 80/20 solution. Any image that is not visible on initial load should have
loading="lazy". It ships in all modern browsers. No JavaScript, no library — one attribute.html<!-- Below-the-fold product image: lazy load it --> <img src="product-photo.jpg" alt="Wooden desk with clean setup" width="800" height="600" loading="lazy" > -
2
Never lazy-load above-the-fold images
Hero images, logo, and anything visible without scrolling must not be lazy-loaded. Lazy loading delays the browser's discovery of the image, directly worsening Largest Contentful Paint (LCP). For your most important above-fold image, go further and add
fetchpriority="high".html<!-- Hero: eager (the default) + high priority --> <img src="hero.jpg" alt="Product hero shot" width="1200" height="675" fetchpriority="high" > <!-- First visible image in a list: eager, no fetchpriority needed --> <img src="first-card.jpg" alt="First card" width="400" height="300" > -
3
Always set explicit width and height
Without explicit dimensions the browser cannot reserve space for the image while it loads. The result is Cumulative Layout Shift (CLS) — content jumps when the image arrives.
widthandheightdo not force a fixed visual size; CSS still controls that. They just give the browser the aspect ratio it needs to pre-allocate space.html<!-- Bad: no dimensions = layout shift --> <img src="photo.jpg" alt="..." loading="lazy"> <!-- Good: aspect ratio locked, no shift --> <img src="photo.jpg" alt="..." width="800" height="533" loading="lazy" > -
4
Use srcset for responsive images
Serve the right resolution for each screen size. The browser picks the best fit from the
srcsetlist. Combine this withloading="lazy"— they work independently.html<img src="photo-800.jpg" srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1200.jpg 1200w" sizes="(max-width: 600px) 100vw, (max-width: 900px) 50vw, 800px" alt="Responsive photo" width="800" height="533" loading="lazy" > -
5
Use <picture> for format switching
Serve WebP to browsers that support it and fall back to JPEG for the rest.
<picture>selects the first<source>the browser can handle.loading="lazy"goes on the<img>fallback — not on the<source>elements.html<picture> <source type="image/webp" srcset="photo-400.webp 400w, photo-800.webp 800w" sizes="(max-width: 600px) 100vw, 800px" > <source type="image/jpeg" srcset="photo-400.jpg 400w, photo-800.jpg 800w" sizes="(max-width: 600px) 100vw, 800px" > <img src="photo-800.jpg" alt="Product photo" width="800" height="533" loading="lazy" > </picture> -
6
Build the IntersectionObserver fallback
For browsers that do not support
loading="lazy"(primarily Safari before iOS 15.4 and older Chrome), use IntersectionObserver. Store the real URL indata-srcand swap it tosrcwhen the image enters the viewport.javascriptif ('loading' in HTMLImageElement.prototype) { // Native lazy loading supported — nothing to do } else { // Fallback: IntersectionObserver const images = document.querySelectorAll('img[data-src]'); const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (!entry.isIntersecting) return; const img = entry.target; img.src = img.dataset.src; if (img.dataset.srcset) img.srcset = img.dataset.srcset; img.removeAttribute('data-src'); observer.unobserve(img); }); }, { rootMargin: '200px' }); // load 200px before it enters view images.forEach(img => observer.observe(img)); } -
7
Mark fallback images with data-src
When the fallback is active, the HTML for lazy images stores the URL in
data-srcinstead ofsrc. Use a transparent 1×1 placeholder insrcso the element is valid. Modern browsers use theloadingattribute and never readdata-src.html<!-- Works with both native lazy loading and the JS fallback --> <img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-src="actual-photo.jpg" data-srcset="actual-photo-400.jpg 400w, actual-photo-800.jpg 800w" alt="Product photo" width="800" height="533" loading="lazy" >
Tips & gotchas
- The first image in a repeated list (cards, products) is often above the fold on large screens — check before adding lazy loading.
- `loading="lazy"` also works on `<iframe>` elements, not just images. Useful for lazy-loading embedded maps or videos.
- Use a `rootMargin` of 200–400px in IntersectionObserver so images start loading before they reach the viewport, avoiding a visible pop-in.
- Run Google Lighthouse or WebPageTest after adding lazy loading. Verify LCP did not get worse — a common mistake is lazy-loading the LCP image.
- For background images in CSS (not `<img>`), there is no native lazy loading. Use IntersectionObserver to add/remove a class that sets the `background-image`.
Wrapping up
For most projects, adding loading="lazy" to all below-fold images and setting explicit width/height on every image is enough to get a significant performance win. The IntersectionObserver fallback is worth adding if you need to support older browsers, but the native attribute covers the vast majority of real-world traffic. The most important rule: never lazy-load your LCP image.