Lazy loading images — the complete guide
Native lazy loading, intersection observers, and common pitfalls. Everything you need to defer off-screen images correctly.
Lazy loading defers the download of off-screen images until the user scrolls near them. It's one of the simplest performance wins available — and it's built into every modern browser.
Native lazy loading
HTML has a loading attribute that handles lazy loading without any JavaScript:
<img src="photo.webp" loading="lazy" width="800" height="600" alt="..." />
That's it. The browser decides when to start downloading based on the user's scroll position and network conditions.
Browser support: Chrome, Firefox, Edge, Safari — all modern browsers. No polyfill needed in 2026.
When to lazy load (and when not to)
Do lazy load:
- Images below the fold (not visible on initial page load)
- Images in long content pages (blog posts, product listings)
- Thumbnails in image galleries
- Background images loaded via CSS (use Intersection Observer)
Don't lazy load:
- The LCP image (hero image, main product photo) — lazy loading delays it
- Images above the fold that are immediately visible
- Critical UI elements like logos
The LCP trap
This is the most common lazy loading mistake. Adding loading="lazy" to your hero image tells the browser to deprioritise it — exactly the opposite of what you want for your Largest Contentful Paint metric.
Instead, preload the LCP image:
<!-- In <head> -->
<link rel="preload" as="image" href="/hero.avif" type="image/avif" />
<!-- In <body> -->
<img src="/hero.avif" width="1200" height="600" alt="..." fetchpriority="high" />
Note: fetchpriority="high" is a newer attribute that tells the browser to prioritise this resource.
Width and height are required
For lazy loading to work without causing layout shift, you must set width and height on every image. This lets the browser reserve the correct space before the image loads.
<!-- Good: space is reserved -->
<img src="photo.webp" loading="lazy" width="800" height="600" alt="..." />
<!-- Bad: causes layout shift when image loads -->
<img src="photo.webp" loading="lazy" alt="..." />
Without dimensions, the image has zero height until it loads, then suddenly pushes content down — exactly the layout shift that CLS penalises.
Intersection Observer (for advanced cases)
If you need more control — custom thresholds, animation triggers, or background image lazy loading — 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);
}
});
},
{ rootMargin: '200px' },
);
document.querySelectorAll('img[data-src]').forEach((img) => {
observer.observe(img);
});
The rootMargin: '200px' starts loading images 200px before they enter the viewport, so they're ready by the time the user scrolls to them.
The decoding attribute
Pair loading="lazy" with decoding="async" for maximum performance:
<img src="photo.webp" loading="lazy" decoding="async" width="800" height="600" alt="..." />
decoding="async" tells the browser to decode the image off the main thread, preventing decode jank.
Measuring the impact
Before and after adding lazy loading, measure:
- Total page weight (Network tab → transferred size)
- LCP (Lighthouse → Performance)
- CLS (must not increase — set width/height!)
- Number of requests on initial load (should decrease)
A blog post with 15 images might go from 3 MB on initial load to 400 KB — with the rest loading on demand as the user scrolls.
Combine with compression
Lazy loading reduces when images load, but compression reduces how much data each image needs. Use both together for the best performance: