Image Optimization: CDN, Lazy Loading, and Modern Formats
TL;DR
Use WebP/AVIF for 30-50% smaller files. Lazy load images below the fold. Serve responsive images with srcset. Use a CDN for fast delivery. Compress before upload. Modern formats + lazy loading = 70% faster loads.
Our homepage loaded in 8 seconds. I checked the waterfall: 47 images totaling 12MB, all loading at once, all uncompressed JPGs. Mobile users were abandoning before anything appeared.
After implementing WebP, lazy loading, and a CDN, the same page loaded in 2.3 seconds with 3.2MB transferred. Bounce rate dropped by 40%. Conversions increased by 28%.
Images are the biggest performance killer on the web - 50% of page weight on average. Here's how to optimize them properly, with real code and actual results.
The Image Performance Problem
Most sites ship way too much image data:
<!-- Typical unoptimized page -->
<img src="hero.jpg" width="1920" height="1080"> <!-- 2.4 MB -->
<img src="product1.jpg" width="800" height="600"> <!-- 890 KB -->
<img src="product2.jpg" width="800" height="600"> <!-- 920 KB -->
<img src="product3.jpg" width="800" height="600"> <!-- 880 KB -->
<img src="banner.png" width="1200" height="400"> <!-- 1.8 MB -->
<!-- ... 42 more images ... -->
<!-- Total: 12 MB of images -->
<!-- Load time on 3G: 45+ seconds -->
The problems:
- Wrong format (PNG for photos, JPG for graphics)
- No compression
- Full resolution on mobile
- All images load immediately
- No CDN caching
- No modern format support
Modern Image Formats: WebP and AVIF
WebP: 25-35% Smaller Than JPG
WebP is a modern format developed by Google. Same quality, much smaller files.
# Convert JPG to WebP
cwebp input.jpg -q 80 -o output.webp
# Results:
# input.jpg: 890 KB
# output.webp: 580 KB (35% smaller!)
Browser support: 97% (all modern browsers)
AVIF: 50% Smaller Than JPG
AVIF is newer and even more efficient than WebP.
# Convert to AVIF
npx @squoosh/cli --avif '{"cqLevel":30}' input.jpg
# Results:
# input.jpg: 890 KB
# output.avif: 445 KB (50% smaller!)
Browser support: 90% (Chrome, Firefox, Safari 16.4+)
Format Comparison
I tested the same image in different formats:
Original: 4032x3024 photo
Quality: Visually identical
JPEG (quality 80): 2.4 MB
PNG-24: 8.1 MB (never use PNG for photos!)
WebP (quality 80): 1.6 MB (33% smaller than JPG)
AVIF (cq 30): 1.2 MB (50% smaller than JPG)
When to use each format:
- AVIF: Photos, complex images (best compression)
- WebP: Photos when AVIF not supported (great compression)
- PNG: Logos, icons, graphics with transparency (lossless)
- JPG: Fallback for old browsers
- SVG: Icons, logos, simple graphics (vector, tiny files)
Serving Modern Formats with Fallbacks
Use the <picture> element for progressive enhancement:
<picture>
<!-- Try AVIF first (newest, smallest) -->
<source srcset="image.avif" type="image/avif">
<!-- Fall back to WebP (widely supported) -->
<source srcset="image.webp" type="image/webp">
<!-- Fall back to JPG (universal) -->
<img src="image.jpg" alt="Description" loading="lazy">
</picture>
<!-- Browser picks the first format it supports -->
<!-- Modern browsers get AVIF (smallest)
Older browsers get WebP
Ancient browsers get JPG -->
Real savings:
- Modern browsers: Download 1.2 MB (AVIF)
- Older browsers: Download 1.6 MB (WebP)
- Ancient browsers: Download 2.4 MB (JPG)
Average savings: 45% smaller downloads
Lazy Loading: Don't Load What You Can't See
Only load images when they're about to enter the viewport.
Native Lazy Loading (Easiest)
<!-- Modern browsers lazy load automatically -->
<img src="image.jpg" loading="lazy" alt="Description">
<!-- Eager loading for above-the-fold images -->
<img src="hero.jpg" loading="eager" alt="Hero image">
Browser support: 95% (all modern browsers)
<!-- Full example with modern formats + lazy loading -->
<picture>
<source srcset="image.avif" type="image/avif">
<source srcset="image.webp" type="image/webp">
<img src="image.jpg" alt="Description" loading="lazy" width="800" height="600">
</picture>
Always include width and height to prevent layout shift!
JavaScript Lazy Loading (For Older Browsers)
// Intersection Observer for progressive loading
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
// Load the actual image
img.src = img.dataset.src;
// Load srcset if present
if (img.dataset.srcset) {
img.srcset = img.dataset.srcset;
}
// Remove blur/placeholder class
img.classList.remove('lazy-loading');
img.classList.add('lazy-loaded');
// Stop observing this image
observer.unobserve(img);
}
});
}, {
// Start loading 200px before image enters viewport
rootMargin: '200px'
});
// Observe all lazy images
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
<!-- HTML for JS lazy loading -->
<img
data-src="image.jpg"
data-srcset="image-400.jpg 400w, image-800.jpg 800w"
src="placeholder.jpg"
class="lazy-loading"
alt="Description"
width="800"
height="600"
>
/* Smooth transition when loaded */
img.lazy-loading {
filter: blur(5px);
transition: filter 0.3s;
}
img.lazy-loaded {
filter: blur(0);
}
Performance Impact
Before lazy loading:
Page load: 8.2s
Images loaded: 47 (12 MB)
LCP: 4.8s
After lazy loading:
Page load: 2.3s
Images loaded initially: 8 (2.4 MB)
LCP: 1.2s (70% faster!)
Only visible images load. Huge improvement!
Responsive Images: Right Size for Each Device
Don't send 4K images to mobile phones.
Using srcset for Resolution Switching
<img
srcset="
image-400.jpg 400w,
image-800.jpg 800w,
image-1200.jpg 1200w,
image-1600.jpg 1600w
"
sizes="(max-width: 600px) 400px,
(max-width: 1200px) 800px,
1200px"
src="image-800.jpg"
alt="Description"
loading="lazy"
>
<!-- Browser automatically picks the right size:
- Mobile (320px wide): Downloads 400w version
- Tablet (768px wide): Downloads 800w version
- Desktop (1920px wide): Downloads 1600w version -->
Result: Mobile users download 80% less data!
Art Direction with Picture
Different crops for different screen sizes:
<picture>
<!-- Mobile: Portrait crop -->
<source
media="(max-width: 600px)"
srcset="hero-mobile.jpg"
>
<!-- Tablet: Square crop -->
<source
media="(max-width: 1200px)"
srcset="hero-tablet.jpg"
>
<!-- Desktop: Wide crop -->
<img src="hero-desktop.jpg" alt="Hero image">
</picture>
Combining Everything
<picture>
<!-- Mobile AVIF -->
<source
media="(max-width: 600px)"
srcset="image-400.avif"
type="image/avif"
>
<!-- Mobile WebP -->
<source
media="(max-width: 600px)"
srcset="image-400.webp"
type="image/webp"
>
<!-- Desktop AVIF -->
<source
srcset="image-1200.avif"
type="image/avif"
>
<!-- Desktop WebP -->
<source
srcset="image-1200.webp"
type="image/webp"
>
<!-- Fallback -->
<img
src="image-800.jpg"
alt="Description"
loading="lazy"
width="800"
height="600"
>
</picture>
Mobile users get small AVIF. Desktop users get large AVIF. Old browsers get JPG.
CDN for Image Delivery
CDNs cache images at edge locations worldwide. Users download from the nearest server.
Image CDN Services
Cloudflare Images:
<!-- Original URL -->
<img src="https://example.com/images/photo.jpg">
<!-- Cloudflare Images with automatic optimization -->
<img src="https://example.com/cdn-cgi/image/width=800,quality=80,format=auto/images/photo.jpg">
<!-- format=auto: Serves WebP/AVIF automatically
width=800: Resizes on CDN
quality=80: Optimizes compression -->
Cloudinary:
<img src="https://res.cloudinary.com/demo/image/upload/w_800,q_auto,f_auto/sample.jpg">
<!-- w_800: Width 800px
q_auto: Automatic quality
f_auto: Automatic format (WebP/AVIF) -->
imgix:
<img src="https://example.imgix.net/photo.jpg?w=800&auto=format,compress">
<!-- auto=format: WebP/AVIF automatically
auto=compress: Smart compression -->
Self-Hosted CDN with Transforms
Using Cloudflare Workers to optimize on-the-fly:
// Cloudflare Worker
export default {
async fetch(request) {
const url = new URL(request.url);
// Parse image params
const width = url.searchParams.get('w') || 'auto';
const quality = url.searchParams.get('q') || '80';
// Get original image
const imageUrl = url.pathname.replace('/cdn/', '');
const response = await fetch(`https://origin.example.com${imageUrl}`);
// Transform with Cloudflare Images
return fetch(response.url, {
cf: {
image: {
width: width,
quality: quality,
format: 'auto' // WebP/AVIF
}
}
});
}
};
<!-- Usage -->
<img src="https://cdn.example.com/cdn/photo.jpg?w=800&q=80">
CDN Performance Impact
Without CDN (origin server in US):
User in US: 120ms
User in Europe: 380ms
User in Asia: 540ms
With CDN (Cloudflare):
User in US: 45ms
User in Europe: 52ms
User in Asia: 58ms
4-10x faster delivery worldwide!
Image Compression
Always compress images before uploading:
Command Line Tools
# JPG compression with mozjpeg
jpegoptim --max=80 image.jpg
# PNG compression
pngquant image.png --quality=65-80 --output image-compressed.png
# WebP conversion
cwebp image.jpg -q 80 -o image.webp
# AVIF conversion
npx @squoosh/cli --avif '{"cqLevel":30}' image.jpg
Node.js Sharp Library
const sharp = require('sharp');
// Resize and optimize
await sharp('input.jpg')
.resize(800, 600, { fit: 'cover' })
.jpeg({ quality: 80, progressive: true })
.toFile('output.jpg');
// Create multiple sizes
const sizes = [400, 800, 1200, 1600];
for (const size of sizes) {
// JPG
await sharp('input.jpg')
.resize(size)
.jpeg({ quality: 80 })
.toFile(`output-${size}.jpg`);
// WebP
await sharp('input.jpg')
.resize(size)
.webp({ quality: 80 })
.toFile(`output-${size}.webp`);
// AVIF
await sharp('input.jpg')
.resize(size)
.avif({ quality: 60 })
.toFile(`output-${size}.avif`);
}
Automated Build Pipeline
// build-images.js
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');
const SIZES = [400, 800, 1200, 1600];
const FORMATS = ['jpg', 'webp', 'avif'];
async function optimizeImage(inputPath, outputDir) {
const filename = path.basename(inputPath, path.extname(inputPath));
for (const size of SIZES) {
for (const format of FORMATS) {
const outputPath = path.join(
outputDir,
`${filename}-${size}.${format}`
);
let pipeline = sharp(inputPath).resize(size);
if (format === 'jpg') {
pipeline = pipeline.jpeg({ quality: 80, progressive: true });
} else if (format === 'webp') {
pipeline = pipeline.webp({ quality: 80 });
} else if (format === 'avif') {
pipeline = pipeline.avif({ quality: 60 });
}
await pipeline.toFile(outputPath);
console.log(`Created: ${outputPath}`);
}
}
}
// Process all images in src/images/
const imagesDir = 'src/images';
const outputDir = 'public/images';
fs.readdirSync(imagesDir).forEach(async (file) => {
if (/\.(jpg|jpeg|png)$/i.test(file)) {
await optimizeImage(
path.join(imagesDir, file),
outputDir
);
}
});
// package.json
{
"scripts": {
"build:images": "node build-images.js",
"build": "npm run build:images && vite build"
}
}
Background Images and CSS
Don't forget CSS background images:
/* Responsive background images */
.hero {
background-image: image-set(
url('hero-1x.avif') 1x type('image/avif'),
url('hero-2x.avif') 2x type('image/avif'),
url('hero-1x.webp') 1x type('image/webp'),
url('hero-2x.webp') 2x type('image/webp'),
url('hero-1x.jpg') 1x,
url('hero-2x.jpg') 2x
);
}
/* Mobile-first responsive backgrounds */
.banner {
background-image: url('banner-mobile.webp');
}
@media (min-width: 768px) {
.banner {
background-image: url('banner-tablet.webp');
}
}
@media (min-width: 1200px) {
.banner {
background-image: url('banner-desktop.webp');
}
}
Placeholder Strategies
Show something while images load:
1. Blur-Up (Medium Style)
<div class="image-wrapper">
<!-- Tiny blurred placeholder (2KB) -->
<img
src="placeholder-tiny.jpg"
class="placeholder"
aria-hidden="true"
>
<!-- Full image loads over top -->
<img
src="image.jpg"
class="main-image"
loading="lazy"
alt="Description"
>
</div>
.image-wrapper {
position: relative;
overflow: hidden;
}
.placeholder {
position: absolute;
filter: blur(20px);
transform: scale(1.1);
}
.main-image {
position: relative;
opacity: 0;
transition: opacity 0.3s;
}
.main-image.loaded {
opacity: 1;
}
// Fade in when loaded
document.querySelectorAll('.main-image').forEach(img => {
img.addEventListener('load', () => {
img.classList.add('loaded');
});
});
2. Low Quality Image Placeholder (LQIP)
// Generate tiny placeholder with Sharp
await sharp('input.jpg')
.resize(20, 20, { fit: 'cover' })
.blur()
.toBuffer()
.then(data => {
const base64 = data.toString('base64');
return `data:image/jpeg;base64,${base64}`;
});
<img
src="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
data-src="image.jpg"
class="lazy"
alt="Description"
>
3. Solid Color Placeholder
<img
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 800 600'%3E%3Crect fill='%23f0f0f0' width='800' height='600'/%3E%3C/svg%3E"
data-src="image.jpg"
loading="lazy"
alt="Description"
>
Or extract dominant color:
const Vibrant = require('node-vibrant');
const palette = await Vibrant.from('image.jpg').getPalette();
const dominantColor = palette.Vibrant.hex;
// Use as placeholder background
Preventing Layout Shift (CLS)
Always specify dimensions to prevent content jumping:
<!-- BAD - No dimensions, causes layout shift -->
<img src="image.jpg" alt="Description">
<!-- GOOD - Explicit dimensions -->
<img
src="image.jpg"
width="800"
height="600"
alt="Description"
>
Aspect Ratio Boxes
/* Maintain aspect ratio while loading */
.image-container {
position: relative;
aspect-ratio: 16 / 9; /* or 800 / 600 */
}
.image-container img {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
}
<div class="image-container">
<img src="image.jpg" alt="Description" loading="lazy">
</div>
Real-World Performance Comparison
I optimized a real e-commerce product page:
Before Optimization
Total images: 32
Total size: 8.4 MB
Formats: JPG, PNG
Delivery: Origin server (no CDN)
Lazy loading: No
Responsive: No
Metrics:
- LCP: 4.2s
- Page load: 7.8s
- Mobile load (3G): 28s
- Bounce rate: 52%
After Optimization
Total images: 32
Total size (modern browsers): 2.1 MB (75% reduction!)
Formats: AVIF, WebP, JPG fallback
Delivery: Cloudflare CDN
Lazy loading: Yes (loads 6 initially)
Responsive: Yes (saves 60% on mobile)
Metrics:
- LCP: 1.2s (71% faster)
- Page load: 2.4s (69% faster)
- Mobile load (3G): 8.5s (70% faster)
- Bounce rate: 31% (40% improvement)
- Conversions: +28%
Cost: ~4 hours of work Result: Massive performance and business improvement
Image Optimization Checklist
- [ ] Use WebP/AVIF with JPG fallback
- [ ] Compress images before upload (80% quality)
- [ ] Generate multiple sizes for responsive images
- [ ] Use srcset and sizes attributes
- [ ] Enable lazy loading (loading="lazy")
- [ ] Specify width and height to prevent CLS
- [ ] Use CDN for delivery
- [ ] Optimize above-the-fold images first
- [ ] Use appropriate formats (AVIF for photos, SVG for icons)
- [ ] Test on mobile connections (3G/4G)
- [ ] Monitor with Lighthouse and WebPageTest
Automated Tools
Online Tools
- Squoosh: https://squoosh.app (GUI for WebP/AVIF conversion)
- TinyPNG: https://tinypng.com (PNG/JPG compression)
- ImageOptim: https://imageoptim.com (Mac app for batch optimization)
Build Tools
// Vite plugin
import imagemin from 'vite-plugin-imagemin';
export default {
plugins: [
imagemin({
gifsicle: { optimizationLevel: 3 },
mozjpeg: { quality: 80 },
pngquant: { quality: [0.65, 0.8] },
webp: { quality: 80 },
avif: { quality: 60 }
})
]
};
// Webpack plugin
const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin');
module.exports = {
optimization: {
minimizer: [
new ImageMinimizerPlugin({
minimizer: {
implementation: ImageMinimizerPlugin.imageminGenerate,
options: {
plugins: [
['imagemin-mozjpeg', { quality: 80 }],
['imagemin-webp', { quality: 80 }],
['imagemin-avif', { quality: 60 }]
]
}
}
})
]
}
};
Common Mistakes
Mistake 1: Using PNG for Photos
<!-- BAD - PNG is huge for photos -->
<img src="photo.png"> <!-- 8.1 MB -->
<!-- GOOD - AVIF/WebP for photos -->
<picture>
<source srcset="photo.avif" type="image/avif"> <!-- 1.2 MB -->
<source srcset="photo.webp" type="image/webp"> <!-- 1.6 MB -->
<img src="photo.jpg" alt="Description"> <!-- 2.4 MB -->
</picture>
Use PNG only for logos, icons, and graphics with transparency.
Mistake 2: Not Specifying Dimensions
<!-- BAD - Causes layout shift -->
<img src="image.jpg" loading="lazy">
<!-- GOOD - Prevents layout shift -->
<img src="image.jpg" width="800" height="600" loading="lazy">
Mistake 3: Lazy Loading Above-the-Fold Images
<!-- BAD - Hero image delayed -->
<img src="hero.jpg" loading="lazy">
<!-- GOOD - Eager load critical images -->
<img src="hero.jpg" loading="eager">
Only lazy load below-the-fold images.
Mistake 4: No Fallback for Modern Formats
<!-- BAD - Breaks in old browsers -->
<img src="image.avif">
<!-- GOOD - Progressive enhancement -->
<picture>
<source srcset="image.avif" type="image/avif">
<source srcset="image.webp" type="image/webp">
<img src="image.jpg" alt="Description">
</picture>
Mistake 5: Over-Optimizing Quality
# TOO aggressive - visible quality loss
cwebp image.jpg -q 40 -o image.webp
# GOOD - Optimal balance
cwebp image.jpg -q 80 -o image.webp
Quality 75-85 is the sweet spot for most images.
Monitoring Image Performance
// Measure image load time
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.initiatorType === 'img') {
console.log(`Image: ${entry.name}`);
console.log(`Size: ${entry.transferSize} bytes`);
console.log(`Duration: ${entry.duration}ms`);
}
}
}).observe({ entryTypes: ['resource'] });
Use Lighthouse to audit:
npm install -g lighthouse
lighthouse https://example.com --only-categories=performance
The Bottom Line
Images are the biggest performance bottleneck on most sites. Optimize them properly and you'll see dramatic improvements.
Use modern formats: WebP and AVIF are 30-50% smaller than JPG.
Lazy load: Don't load images users can't see yet. Saves bandwidth and speeds up initial load.
Responsive images: Serve appropriate sizes for each device. Mobile users don't need 4K images.
Use a CDN: Deliver images from edge servers worldwide. 4-10x faster than origin servers.
Compress before upload: 80% quality is visually identical with 60% smaller files.
Our homepage went from 8 seconds to 2.3 seconds. Bounce rate dropped 40%. Conversions increased 28%. All from optimizing images properly.
Check your site's images today. Run Lighthouse. Fix the low-hanging fruit. The performance gains are worth it.