
If your website is packed with rich photography, product galleries, magazine-style imagery, hero banners, icons, or user-generated visuals, you already know the challenge: images make your brand look great, but they can also slow your pages down and hurt conversions, SEO, and user satisfaction. The good news is that modern browsers and tooling give you powerful control over how, when, and in what quality images are delivered. Lazy loading, responsive images, modern file formats, and smart delivery strategies can transform an image-heavy site from sluggish to snappy.
In this comprehensive guide, you will learn not only how to toggle on lazy loading, but also how to combine it with advanced optimisation techniques to boost Core Web Vitals, reduce bandwidth costs, and serve consistently crisp images across devices — without sacrificing aesthetics or discoverability.
loading='lazy' attribute for most images and combine it with responsive images (srcset, sizes) and modern formats (AVIF, WebP).fetchpriority='high', decoding='async', and optional preload hints.aspect-ratio property to prevent cumulative layout shift.Images typically account for the largest share of a page’s total bytes. Large hero banners, carousels, product grids, editorial photography, and background textures quickly add up. Without careful optimisation, this leads to:
A 100 KB savings might not sound like much, but multiply it by dozens of images and thousands of pageviews, and the impact becomes substantial. Mobile users on constrained connections will especially benefit from better image delivery.
Lazy loading defers fetching an image until it is needed — that is, when it is about to enter the viewport. This reduces the amount of data required for the initial render and speeds up the first view.
There are two broad approaches:
loading='lazy' attributeNative lazy loading is now widely supported, simple, and performant. JavaScript approaches offer extra control and custom effects, and they remain useful for legacy browsers or advanced placeholders.
The critical path to render the above-the-fold content is shorter because the browser skips network requests for below-the-fold resources. When combined with responsive images, only the right sizes for the current device are requested, reducing waste further.
You should almost always lazy load non-critical images that appear below the fold. Typical candidates include:
Avoid lazy loading for critical, above-the-fold content such as:
If you lazy load the hero image, you risk delaying LCP significantly. Prioritise it instead using priority hints and appropriate preloading.
Most modern browsers support native lazy loading via a single attribute on images and iframes. It is the easiest and most reliable option in 2025 for standard cases.
<img src='gallery-item-1024.jpg' alt='Sunset over the valley' loading='lazy' width='1024' height='768' />
Key points:
loading='lazy' tells the browser to defer loading until the image is close to entering the viewport.decoding='async' to improve rendering throughput in some cases.<img src='gallery-item-1024.jpg' alt='Sunset over the valley' loading='lazy' decoding='async' width='1024' height='768' />
When an image is important, but you are not lazy loading it, you can signal priority using fetchpriority.
<img src='hero-1600.jpg' alt='Hero scene' width='1600' height='900' fetchpriority='high' decoding='async' />
For non-critical images that should be loaded slowly, you can optionally set fetchpriority='low'.
<img src='decorative-ornament.webp' alt='' width='512' height='512' loading='lazy' fetchpriority='low' />
Note: do not set low priority on images that may soon contribute to LCP.
loading attribute optionsloading='eager': load immediately (default for above-the-fold)loading='lazy': defer until near viewportMost browsers auto-detect and treat above-the-fold images as eager. Using loading='lazy' for a hero might slow down your LCP.
For nuanced control or to support custom placeholder transitions, IntersectionObserver offers a performant and flexible way to implement lazy loading in vanilla JS or frameworks.
<img class='lazy' data-src='photo-1200.webp' alt='Cliffside view' width='1200' height='800' />
Note the use of data-src to store the actual image URL until the observer swaps it in.
const lazyImages = document.querySelectorAll('img.lazy');
const onIntersection = (entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
const src = img.getAttribute('data-src');
if (src) {
img.src = src;
img.removeAttribute('data-src');
}
img.classList.remove('lazy');
observer.unobserve(img);
}
});
};
const io = new IntersectionObserver(onIntersection, {
root: null,
rootMargin: '200px 0px',
threshold: 0.01
});
lazyImages.forEach(img => io.observe(img));
Tips:
rootMargin (for example 200px) to prefetch images slightly before they enter the viewport, preventing jank.IntersectionObserver is widely supported, but for legacy browsers you can lazy load after scroll events with throttling, or load all images if the feature is missing.
if ('IntersectionObserver' in window) {
// Use the IO strategy above
} else {
// Simple fallback: load images after window load
window.addEventListener('load', () => {
document.querySelectorAll('img.lazy').forEach(img => {
const src = img.getAttribute('data-src');
if (src) img.src = src;
});
});
}
Lazy loading helps with when to load. Responsive images decide what to load. Together, they ensure the browser requests an appropriately sized image for the current viewport and DPR (device pixel ratio).
srcset with width descriptors<img
src='landscape-800.jpg'
srcset='
landscape-480.jpg 480w,
landscape-800.jpg 800w,
landscape-1200.jpg 1200w,
landscape-1600.jpg 1600w
'
sizes='(max-width: 600px) 90vw, (max-width: 1200px) 80vw, 1200px'
loading='lazy'
alt='Mountain landscape'
width='1600' height='900'
/>
How it works:
srcset lists variants and their intrinsic widths. The browser picks the best candidate based on sizes and device DPR.sizes describes how much viewport width the image will occupy under various conditions. This is critical; without it, the browser may choose a larger-than-needed file.<picture>Use <picture> to swap not just sizes or formats, but also the actual imagery depending on layout breakpoints.
<picture>
<source type='image/avif' srcset='hero-800.avif 800w, hero-1600.avif 1600w' sizes='100vw' />
<source type='image/webp' srcset='hero-800.webp 800w, hero-1600.webp 1600w' sizes='100vw' />
<img src='hero-1600.jpg' alt='Hero banner' width='1600' height='900' fetchpriority='high' decoding='async' />
</picture>
sizes, you can fine-tune which width variant is selected.img fallback for older browsers.sizes fundamentalsThink of sizes as a map from CSS breakpoints to the expected layout width of the image. Common patterns:
sizes='100vw'sizes='(min-width: 1200px) 1200px, 90vw'sizes='(min-width: 1024px) 50vw, 90vw'Getting sizes right is one of the biggest wins for image-heavy sites, ensuring the browser does not overfetch large files.
For icons and small images, you can use density descriptors (1x, 2x) instead of widths.
<img src='icon@1x.png' srcset='icon@1x.png 1x, icon@2x.png 2x' alt='App icon' width='64' height='64' loading='lazy' />
File format choice significantly affects size and quality.
<picture> with AVIF and WebP<picture>
<source type='image/avif' srcset='product-600.avif 600w, product-1200.avif 1200w' sizes='(max-width: 600px) 90vw, 600px' />
<source type='image/webp' srcset='product-600.webp 600w, product-1200.webp 1200w' sizes='(max-width: 600px) 90vw, 600px' />
<img src='product-1200.jpg' alt='Product close-up' width='1200' height='1200' loading='lazy' decoding='async' />
</picture>
Compression is not one-size-fits-all. Striking the right balance between visual quality and file size requires strategy.
const sharp = require('sharp');
const sizes = [480, 800, 1200, 1600];
const formats = ['avif', 'webp', 'jpeg'];
async function processImage(input, baseName) {
for (const width of sizes) {
const pipeline = sharp(input).resize({ width, withoutEnlargement: true });
await pipeline.clone().avif({ quality: 45 }).toFile(`${baseName}-${width}.avif`);
await pipeline.clone().webp({ quality: 70 }).toFile(`${baseName}-${width}.webp`);
await pipeline.clone().jpeg({ quality: 75, progressive: true }).toFile(`${baseName}-${width}.jpg`);
}
}
processImage('hero-original.jpg', 'hero').catch(console.error);
On-demand pipelines reduce storage complexity and serve only what is needed per request.
A great UX does not show empty boxes. Use lightweight placeholders while the full image loads.
<style>
.placeholder { filter: blur(20px); transform: scale(1.05); transition: filter 300ms ease, transform 300ms ease; }
.loaded { filter: blur(0); transform: scale(1); }
</style>
<img
class='lazy placeholder'
data-src='gallery-1200.webp'
src='gallery-20.webp'
alt='Gallery piece'
width='1200' height='800'
/>
<script>
const images = document.querySelectorAll('img.lazy');
const io = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
const fullSrc = img.getAttribute('data-src');
const tmp = new Image();
tmp.src = fullSrc;
tmp.onload = () => {
img.src = fullSrc;
img.classList.remove('placeholder');
img.classList.add('loaded');
};
io.unobserve(img);
}
});
}, { rootMargin: '200px' });
images.forEach(img => io.observe(img));
</script>
This pattern shows a tiny placeholder right away and smoothly transitions to the full image once it is fully loaded, avoiding half-rendered visuals.
<style>
.ratio-box { position: relative; width: 100%; aspect-ratio: 4 / 3; background: #cdb4db; }
.ratio-box img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
</style>
<div class='ratio-box'>
<img src='thumb-20.avif' data-src='thumb-1200.avif' alt='Pastel flowers' class='lazy' />
</div>
The aspect-ratio ensures space is reserved, preventing layout shift while the high-res image loads.
Background images in CSS do not have a native lazy loading attribute. Use strategies to delay or conditionally load them.
<div class='hero-bg lazy-bg' data-bg='hero-1600.avif'>
<h1>Welcome</h1>
</div>
<style>
.hero-bg { min-height: 60vh; background-size: cover; background-position: center; }
</style>
<script>
const lazyBackgrounds = document.querySelectorAll('.lazy-bg');
const io = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const el = entry.target;
const url = el.getAttribute('data-bg');
el.style.backgroundImage = `url(${url})`;
el.classList.remove('lazy-bg');
io.unobserve(el);
}
});
});
lazyBackgrounds.forEach(el => io.observe(el));
</script>
Media embeds can be heavy. Lazy load them to avoid blocking the main thread and network.
<iframe
src='https://www.youtube-nocookie.com/embed/VIDEO_ID'
title='Demo video'
loading='lazy'
width='560' height='315'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
allowfullscreen
></iframe>
Replace the iframe with a thumbnail until the user clicks play.
<div class='video'>
<button class='video__btn' aria-label='Play video'>
<img src='https://i.ytimg.com/vi/VIDEO_ID/hqdefault.jpg' alt='Video thumbnail' width='480' height='270' loading='lazy' />
</button>
</div>
<script>
document.querySelectorAll('.video__btn').forEach(btn => {
btn.addEventListener('click', () => {
const wrapper = btn.parentElement;
const iframe = document.createElement('iframe');
iframe.src = 'https://www.youtube-nocookie.com/embed/VIDEO_ID?autoplay=1';
iframe.title = 'Demo video';
iframe.width = '560';
iframe.height = '315';
iframe.allow = 'autoplay; encrypted-media; picture-in-picture';
iframe.allowFullscreen = true;
wrapper.innerHTML = '';
wrapper.appendChild(iframe);
});
});
</script>
This approach drastically reduces initial payloads on pages with many embedded videos.
Your image strategy should be aligned with Core Web Vitals.
Often the LCP is a hero image or a large block of text. Improve LCP by:
fetchpriority='high'Images without reserved dimensions cause content to jump around. Prevent CLS by:
aspect-ratio for flexible layoutsHeavy images can contribute indirectly to INP by congesting the network and main thread. Optimisation helps by reducing decode time and CPU overhead.
If you know which image will be the LCP element, preload it. Make sure to preload the exact resource you will use, including format and size.
<link
rel='preload'
as='image'
href='/images/hero-1600.avif'
imagesrcset='/images/hero-800.avif 800w, /images/hero-1600.avif 1600w'
imagesizes='100vw'
fetchpriority='high'
/>
imagesrcset and imagesizes with rel='preload' to guide the browser to the right variant.decoding='async' lets the browser decode images off the main thread when possible.decoding='async' generally helps; test with and without.Overusing high priority or preloading everything defeats the purpose. Reserve these for critical images, typically the LCP and above-the-fold elements.
An image CDN can transform, compress, and cache your images at the edge, handling device differences, formats, and geographies.
Cache-Control: public, max-age=31536000, immutable for fingerprinted or content-addressed images.If you rely on server-side format negotiation, set the Vary: Accept header to reflect that the response may vary by client capabilities. However, prefer explicit URLs when possible for caching clarity.
preconnect to your image CDN if it is on a different domain.<link rel='preconnect' href='https://images.example-cdn.com' crossorigin>
dns-prefetch for subresources loaded late.<link rel='dns-prefetch' href='//images.example-cdn.com'>
Optimisation without measurement is guesswork. Use both lab and field data.
<script type='module'>
import { onLCP, onCLS, onINP } from 'https://unpkg.com/web-vitals@4/dist/web-vitals.js';
function sendToAnalytics(metric) {
navigator.sendBeacon('/vitals', JSON.stringify(metric));
}
onLCP(sendToAnalytics);
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
</script>
Every platform has nuances. Use built-in tools where appropriate.
loading='lazy' by default.// functions.php
add_filter('wp_lazy_loading_enabled', function($default, $tag_name, $context) {
if ($tag_name === 'img' && $context === 'the_content') {
// Keep lazy loading but you can add logic to skip the first image
}
return $default;
}, 10, 3);
next/image component for automatic optimisations, responsive sizing, and lazy loading.import Image from 'next/image';
export default function Hero() {
return (
<Image
src='/images/hero-1600.jpg'
alt='Hero'
width={1600}
height={900}
priority
sizes='100vw'
style={{ width: '100%', height: 'auto' }}
/>
);
}
priority signals that this image is critical (do not lazy load). For other images, next/image lazy loads by default.next.config.js if images are on an external domain.import { useEffect, useRef, useState } from 'react';
function useIntersection(options) {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node) return;
const io = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
setVisible(true);
io.disconnect();
}
});
}, options);
io.observe(node);
return () => io.disconnect();
}, [options]);
return [ref, visible];
}
export function LazyImage({ placeholder, src, alt, ...rest }) {
const [ref, visible] = useIntersection({ rootMargin: '200px' });
const [loaded, setLoaded] = useState(false);
return (
<img
ref={ref}
src={visible ? src : placeholder}
alt={alt}
onLoad={() => setLoaded(true)}
className={loaded ? 'loaded' : 'placeholder'}
{...rest}
/>
);
}
v-intersect and plugins like vue-lazyload offer simple integration.<template>
<NuxtImg src='/images/product.jpg' sizes='(max-width: 768px) 90vw, 600px' format='webp' alt='Product' />
</template>
<img
src='{{ product.featured_image | image_url: width: 800 }}'
srcset='{{ product.featured_image | image_url: width: 400 }} 400w, {{ product.featured_image | image_url: width: 800 }} 800w'
sizes='(max-width: 600px) 90vw, 800px'
alt='{{ product.title | escape }}'
loading='lazy'
width='800' height='800'
/>
<picture> or srcset accordingly.Optimising images is not just about speed. It is also about discoverability and inclusivity.
<figure> and <figcaption> when appropriate.<noscript> fallbacks with direct <img> tags. While less necessary today, it can help if your JS is critical for image loading and might fail.image fields with fully qualified URLs.For image optimisation to stick, it must be baked into your processes.
Here is a practical plan to implement lazy loading and image optimisation on an image-heavy site.
fetchpriority='high' for the LCP candidate.srcset and sizes that reflect actual layout.<picture> for art direction or format switching.aspect-ratio to prevent CLS.preconnect if cross-origin.Vary: Accept if negotiating formats server-side.rel='preload' and fetchpriority='high'.srcset and sizes for responsive deliveryModern crawlers can process native lazy loading and standard IO-based lazy loading patterns. Make sure images are in the HTML markup with proper attributes. For complex, JS-only scenarios, consider a simple <noscript> fallback for critical content or ensure server-side rendering provides initial markup. Also maintain image sitemaps for large catalogs.
Usually no. The first image is often your LCP element. Load it eagerly with fetchpriority='high', decoding='async', and consider preloading. Lazy load additional gallery images and thumbnails.
Enough to span your design’s major breakpoints and track actual traffic. Common widths include 320, 480, 640, 768, 1024, 1280, 1536, 1920. Use analytics to reduce rarely used sizes. Avoid generating too many variants that bloat storage.
AVIF often yields smaller files for photographs at similar perceptual quality, but encoding can be slower, and results vary by content. Keep WebP as a reliable fallback. Test visually and measure bytes saved. Some workflows still prefer WebP for a balance of speed and quality.
Progressive JPEG can improve perceived loading on slow connections by displaying a coarse version early. If your pipeline supports it, it is a reasonable default for JPEG. However, when serving AVIF or WebP, progressive JPEG may be less relevant as a primary format.
For most use cases, native lazy loading works well and is simpler. Use JavaScript-based lazy loading if you require custom effects, need to lazy load CSS background images, or must support advanced placeholder animations and thresholds.
Yes, if you do not reserve space. Always specify width and height or use aspect-ratio to prevent layout jumps. Also avoid inserting new elements above existing content after load.
Use IntersectionObserver to detect when an element enters the viewport and set el.style.backgroundImage to the actual URL. Use a placeholder background color and reserve space using CSS aspect-ratio or fixed height to prevent shifts.
No. Preloading is for critical resources only. Preloading too many assets delays other essential work and can worsen performance. Preload the LCP image and any unavoidable above-the-fold images.
sizes attribute is correctUse the browser’s DevTools to inspect which image candidate is downloaded at different viewport widths. If the browser frequently downloads a larger resource than necessary, your sizes rules may be too generous. Use WebPageTest to see candidate choices across devices.
Want a personalised performance audit of your image-heavy pages with actionable steps and code-level recommendations
Let us help you elevate visual quality without sacrificing speed or SEO. Reach out today to schedule your consultation.
Success with image-heavy sites is about balance. Your users expect rich, immersive visuals, but they also demand speed, stability, and responsiveness. Lazy loading is a cornerstone tactic, but it reaches its full potential only when combined with responsive images, modern formats, solid caching, and a culture of continuous measurement.
Start with the basics: do not lazy load the hero, mark everything else lazy, reserve space to avoid CLS, and serve the right size and format. Then iterate: refine sizes, adopt an image CDN, add placeholders, and monitor field data. As you embed these practices into your design system and content workflows, performance becomes a default, not an afterthought.
The payoff is substantial: happier users, stronger Core Web Vitals, better SEO, and a healthier bottom line. Your images can dazzle without dragging you down — and now you have the playbook to make it happen.
Loading comments...