How to Lazy Load Images the Right Way
Images are usually the heaviest assets on any web page. A typical e-commerce page loads 8-15 MB of images, but the user only sees the first 2-3 when the page opens. Lazy loading defers off-screen images until the user scrolls near them. It is the single biggest performance win you can get with one line of code.
The simplest approach: native lazy loading
Modern browsers support lazy loading natively with a single HTML attribute. No JavaScript library required.
<img src="product-photo.jpg"
loading="lazy"
width="800"
height="600"
alt="Blue running shoes side view" />That is it. The browser decides when to start fetching based on scroll position. Chrome starts loading about 1250 pixels before an image enters the viewport on fast connections, and 2500 pixels on slow connections.
Always include width and height. Without dimensions, the browser cannot reserve space in the layout, which causes Cumulative Layout Shift (CLS) โ one of the three Core Web Vitals.
What NOT to lazy load
Your hero image, logo, and any image visible in the initial viewport should load eagerly. Lazy loading these hurts your Largest Contentful Paint (LCP) because you are telling the browser to delay the most important visual on the page.
<!-- Hero: load eagerly with high priority -->
<img src="hero.jpg"
loading="eager"
fetchpriority="high"
width="1200" height="600" alt="..." />
<!-- Below the fold: lazy load -->
<img src="feature-1.jpg" loading="lazy"
width="600" height="400" alt="..." />
<img src="feature-2.jpg" loading="lazy"
width="600" height="400" alt="..." />The fetchpriority="high" attribute tells the browser to prioritize your hero image in the loading queue. Pair it with a <link rel="preload"> in your document head for even faster delivery.
Intersection Observer for custom behavior
Need custom loading thresholds, fade-in animations, or loading indicators? 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;
img.classList.add('loaded');
observer.unobserve(img);
}
});
}, { rootMargin: '200px' });
document.querySelectorAll('img[data-src]')
.forEach(img => observer.observe(img));The rootMargin option controls how far ahead of the viewport the observer fires. Setting it to 200px means images start loading when they are 200 pixels below the visible area, giving the browser time to fetch before the user scrolls to them.
Lazy loading in React and Next.js
The Next.js Image component handles lazy loading automatically. Below-the-fold images are lazy loaded by default. Above-the-fold images get the priority prop.
import Image from 'next/image';
// Above the fold โ priority loading
<Image src="/hero.jpg" priority
width={1200} height={600} alt="..." />
// Below the fold โ lazy loaded by default
<Image src="/feature.jpg"
width={600} height={400} alt="..." />The Next.js Image component also generates responsive srcset attributes and serves modern formats (WebP/AVIF) when supported. You get lazy loading, responsive images, and format optimization from a single component.
Lazy loading + compression = fastest pages
Lazy loading controls when images load. Compression controls how large each image is. Together, they are the two most effective image performance techniques.
Compress your images first using a tool like MiniPx, then serve them with lazy loading. A compressed, lazy-loaded image gallery loads 10x faster than an uncompressed, eagerly-loaded one. On a page with 20 images, that is the difference between a 1.5 MB initial load and a 15 MB one.
Your Core Web Vitals will thank you โ faster LCP, lower total page weight, and less wasted bandwidth for users who never scroll to the bottom.
Frequently asked questions
Related tools
Compress, convert, and resize images in your browser. Nothing gets uploaded.
Open MiniPx โ