Why Image Optimization Matters
Images account for roughly 50% of total page weight on the average website. Unoptimized images are the single biggest contributor to slow LCP scores and wasted bandwidth.
Average page weight breakdown:
+---------------------------------------------------+
| Images |###################### | 50% |
| JavaScript |########### | 22% |
| CSS |#### | 8% |
| Fonts |### | 6% |
| HTML |## | 4% |
| Other |##### | 10% |
+---------------------------------------------------+
A single unoptimized hero image can be 2-5MB.
The same image optimized: 50-200KB.
That is a 10-40x reduction.
Image Format Comparison
Choosing the right format is the highest-impact optimization you can make.
Format Overview
+--------+----------+--------+----------+---------+------------+
| Format | Type | Alpha | Animate | Quality | Browser |
+--------+----------+--------+----------+---------+------------+
| JPEG | Lossy | No | No | Good | Universal |
| PNG | Lossless | Yes | No | Perfect | Universal |
| WebP | Both | Yes | Yes | Better | 97%+ |
| AVIF | Lossy | Yes | Yes | Best | 93%+ |
| SVG | Vector | Yes | Yes | Infinite| Universal |
| GIF | Lossless | Binary | Yes | Poor | Universal |
+--------+----------+--------+----------+---------+------------+
Size Comparison (Same Visual Quality)
Same 1200x800 photograph at equivalent visual quality:
+--------+-----------+------------------+
| Format | File Size | vs JPEG |
+--------+-----------+------------------+
| PNG | 2,400KB | 4.8x larger |
| JPEG | 500KB | baseline |
| WebP | 350KB | 30% smaller |
| AVIF | 200KB | 60% smaller |
+--------+-----------+------------------+
Same 1200x800 illustration/graphic:
+--------+-----------+------------------+
| Format | File Size | vs PNG |
+--------+-----------+------------------+
| PNG | 800KB | baseline |
| WebP | 250KB | 69% smaller |
| AVIF | 150KB | 81% smaller |
| SVG | 15KB* | 98% smaller |
+--------+-----------+------------------+
* SVG only for vector graphics, not photographs
When to Use Each Format
// Decision tree for image format selection
function chooseFormat(image) {
if (image.type === 'icon' || image.type === 'logo' || image.type === 'illustration') {
return 'SVG'; // Scalable, tiny, perfect quality
}
if (image.needsAnimation) {
return 'WebP or AVIF'; // Replace GIF (much smaller)
}
if (image.needsTransparency) {
return 'WebP with PNG fallback'; // Alpha channel support
}
// For photographs:
return 'AVIF with WebP fallback, JPEG as last resort';
}
Serving Multiple Formats
<!-- Use <picture> to serve the best format each browser supports -->
<picture>
<!-- AVIF: smallest, best quality — served to supporting browsers -->
<source srcset="/hero.avif" type="image/avif" />
<!-- WebP: good quality — fallback for browsers without AVIF -->
<source srcset="/hero.webp" type="image/webp" />
<!-- JPEG: universal fallback -->
<img src="/hero.jpg" alt="Hero image" width="1200" height="600" />
</picture>
Converting Images
# Convert to WebP using cwebp (Google's tool)
cwebp -q 80 input.jpg -o output.webp
# Convert to AVIF using avifenc
avifenc --min 20 --max 30 input.jpg output.avif
# Batch convert with sharp (Node.js)
npm install sharp
// scripts/convert-images.js
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');
async function convertImage(inputPath) {
const basename = path.basename(inputPath, path.extname(inputPath));
const dir = path.dirname(inputPath);
// Generate WebP
await sharp(inputPath)
.webp({ quality: 80 })
.toFile(path.join(dir, `${basename}.webp`));
// Generate AVIF
await sharp(inputPath)
.avif({ quality: 50 }) // AVIF quality scale is different
.toFile(path.join(dir, `${basename}.avif`));
// Generate multiple sizes for responsive images
for (const width of [640, 768, 1024, 1280, 1920]) {
await sharp(inputPath)
.resize(width)
.webp({ quality: 80 })
.toFile(path.join(dir, `${basename}-${width}w.webp`));
}
}
// Process all images in a directory
const imageDir = './public/images';
const files = fs.readdirSync(imageDir).filter(f => /\.(jpg|jpeg|png)$/i.test(f));
for (const file of files) {
convertImage(path.join(imageDir, file));
console.log(`Converted: ${file}`);
}
Responsive Images
Different screen sizes need different image sizes. Shipping a 4K image to a phone on 3G is wasteful.
The srcset Attribute
<!-- srcset tells the browser what sizes are available -->
<img
src="/photo-800w.jpg"
srcset="
/photo-400w.jpg 400w,
/photo-800w.jpg 800w,
/photo-1200w.jpg 1200w,
/photo-1600w.jpg 1600w
"
sizes="(max-width: 600px) 100vw,
(max-width: 1024px) 50vw,
800px"
alt="Responsive photo"
width="1600"
height="900"
/>
How the Browser Chooses
Screen: 375px wide, 2x DPR (iPhone)
sizes says: 100vw at this width = 375px
Effective pixels needed: 375 * 2 = 750px
Browser picks: /photo-800w.jpg (closest >= 750)
Screen: 1440px wide, 1x DPR (desktop)
sizes says: 800px at this width
Effective pixels needed: 800 * 1 = 800px
Browser picks: /photo-800w.jpg (exact match)
Screen: 1440px wide, 2x DPR (Retina desktop)
sizes says: 800px at this width
Effective pixels needed: 800 * 2 = 1600px
Browser picks: /photo-1600w.jpg
The sizes Attribute
The sizes attribute tells the browser how wide the image will be at each viewport size, before the CSS is parsed.
<!--
sizes syntax: (media-condition) size, ... , default-size
Reads as:
- If viewport <= 600px, image is 100% of viewport width
- If viewport <= 1024px, image is 50% of viewport width
- Otherwise, image is 800px wide
-->
<img
srcset="/img-400.jpg 400w, /img-800.jpg 800w, /img-1200.jpg 1200w"
sizes="(max-width: 600px) 100vw,
(max-width: 1024px) 50vw,
800px"
alt="Example"
/>
Art Direction with <picture>
<!-- Different crops for different screen sizes -->
<picture>
<!-- Mobile: Square crop, focus on subject -->
<source
media="(max-width: 600px)"
srcset="/hero-mobile-400.webp 400w, /hero-mobile-600.webp 600w"
sizes="100vw"
/>
<!-- Tablet: 4:3 crop -->
<source
media="(max-width: 1024px)"
srcset="/hero-tablet-768.webp 768w, /hero-tablet-1024.webp 1024w"
sizes="100vw"
/>
<!-- Desktop: Wide panoramic crop -->
<source
srcset="/hero-desktop-1200.webp 1200w, /hero-desktop-1920.webp 1920w"
sizes="100vw"
/>
<img src="/hero-desktop-1200.jpg" alt="Hero" width="1920" height="600" />
</picture>
Lazy Loading Images
Images below the fold should not load until the user scrolls near them.
Native Lazy Loading
<!-- Browser-native lazy loading (supported in all modern browsers) -->
<img
src="/photo.jpg"
alt="Photo"
width="800"
height="600"
loading="lazy" <!-- Defers loading until near viewport -->
decoding="async" <!-- Decodes off main thread -->
/>
<!-- NEVER lazy-load the LCP image (hero/above-the-fold) -->
<img
src="/hero.jpg"
alt="Hero"
width="1200"
height="600"
loading="eager" <!-- Load immediately (default) -->
fetchpriority="high" <!-- Prioritize this download -->
/>
loading="lazy" vs Intersection Observer
+-------------------+---------------------------+---------------------------+
| Feature | loading="lazy" | IntersectionObserver |
+-------------------+---------------------------+---------------------------+
| Setup | Zero JavaScript | Requires JS |
| Control | Browser decides threshold | Custom rootMargin |
| Placeholder | None (blank until loaded) | Custom (blur, skeleton) |
| Animation | None | Fade-in, etc. |
| Support | 95%+ browsers | 97%+ browsers |
| Recommendation | Default choice | When you need custom UX |
+-------------------+---------------------------+---------------------------+
Custom Lazy Loading with Blur Placeholder
import { useState, useRef, useEffect } from 'react';
function LazyImage({ src, alt, width, height, blurHash }) {
const [loaded, setLoaded] = useState(false);
const [inView, setInView] = useState(false);
const ref = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
style={{
width,
height,
background: loaded ? 'none' : '#e0e0e0',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Blurred placeholder */}
{!loaded && (
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: `url(${blurHash})`,
backgroundSize: 'cover',
filter: 'blur(20px)',
transform: 'scale(1.1)',
}}
/>
)}
{/* Actual image loads when in view */}
{inView && (
<img
src={src}
alt={alt}
width={width}
height={height}
onLoad={() => setLoaded(true)}
style={{
opacity: loaded ? 1 : 0,
transition: 'opacity 0.3s ease',
display: 'block',
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
)}
</div>
);
}
CDN Image Delivery
A CDN (Content Delivery Network) serves images from edge servers close to the user, reducing latency and enabling on-the-fly transformations.
CDN Benefits
Without CDN:
User (Sydney) --> Origin Server (New York) = 200ms latency
User (London) --> Origin Server (New York) = 80ms latency
With CDN:
User (Sydney) --> CDN Edge (Sydney) = 10ms latency
User (London) --> CDN Edge (London) = 5ms latency
Image CDN URL Patterns
// Cloudinary
const cloudinaryUrl = (id, transforms) =>
`https://res.cloudinary.com/demo/image/upload/${transforms}/${id}`;
// Usage
cloudinaryUrl('hero.jpg', 'w_800,h_600,c_fill,f_auto,q_auto');
// f_auto = automatic format (AVIF/WebP/JPEG based on browser)
// q_auto = automatic quality optimization
// Imgix
const imgixUrl = (path, params) => {
const searchParams = new URLSearchParams(params);
return `https://your-domain.imgix.net${path}?${searchParams}`;
};
imgixUrl('/hero.jpg', { w: 800, h: 600, fit: 'crop', auto: 'format,compress' });
// Vercel Image Optimization (built into Next.js)
// Automatically handled by next/image component
Implementing an Image Component with CDN
function OptimizedImage({ src, alt, width, height, sizes }) {
// Generate srcset for multiple widths
const widths = [640, 750, 828, 1080, 1200, 1920];
const srcset = widths
.filter((w) => w <= width * 2) // Don't generate sizes larger than 2x
.map((w) => {
const url = `https://cdn.example.com/image/${src}?w=${w}&q=80&f=auto`;
return `${url} ${w}w`;
})
.join(', ');
return (
<img
src={`https://cdn.example.com/image/${src}?w=${width}&q=80&f=auto`}
srcSet={srcset}
sizes={sizes || '100vw'}
alt={alt}
width={width}
height={height}
loading="lazy"
decoding="async"
/>
);
}
next/image Optimization
Next.js provides a built-in Image component that handles format conversion, resizing, lazy loading, and blur placeholders automatically.
Basic Usage
import Image from 'next/image';
// Static import (build-time optimization)
import heroImage from '../public/hero.jpg';
function Hero() {
return (
<Image
src={heroImage}
alt="Hero"
placeholder="blur" // Auto-generated blur placeholder
priority // Preloads this image (for LCP elements)
sizes="100vw"
/>
);
}
Remote Images
// next.config.js — whitelist remote image domains
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
{
protocol: 'https',
hostname: 'cdn.example.com',
},
],
// Customize generated sizes
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
// Prefer modern formats
formats: ['image/avif', 'image/webp'],
},
};
// Remote image with explicit dimensions
<Image
src="https://images.unsplash.com/photo-12345"
alt="Remote photo"
width={1200}
height={800}
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
Fill Mode for Responsive Containers
// When you don't know the exact dimensions
function CardImage({ src, alt }) {
return (
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9' }}>
<Image
src={src}
alt={alt}
fill
sizes="(max-width: 768px) 100vw, 33vw"
style={{ objectFit: 'cover' }}
/>
</div>
);
}
Blur Placeholder for Remote Images
// Generate blur data URL at build time
import { getPlaiceholder } from 'plaiceholder';
export async function getStaticProps() {
const src = 'https://images.unsplash.com/photo-12345';
const buffer = await fetch(src).then(r => r.arrayBuffer());
const { base64 } = await getPlaiceholder(Buffer.from(buffer));
return {
props: {
imageProps: {
src,
blurDataURL: base64,
},
},
};
}
function Page({ imageProps }) {
return (
<Image
src={imageProps.src}
alt="Photo"
width={1200}
height={800}
placeholder="blur"
blurDataURL={imageProps.blurDataURL}
/>
);
}
next/image Generated Output
What next/image does under the hood:
Original: /hero.jpg (2MB, 4000x2000, JPEG)
Generated at request time:
/_next/image?url=/hero.jpg&w=640&q=75 -> 25KB (WebP/AVIF)
/_next/image?url=/hero.jpg&w=750&q=75 -> 32KB (WebP/AVIF)
/_next/image?url=/hero.jpg&w=828&q=75 -> 38KB (WebP/AVIF)
/_next/image?url=/hero.jpg&w=1080&q=75 -> 55KB (WebP/AVIF)
/_next/image?url=/hero.jpg&w=1200&q=75 -> 65KB (WebP/AVIF)
/_next/image?url=/hero.jpg&w=1920&q=75 -> 110KB (WebP/AVIF)
The browser gets the right size in the right format automatically.
Cached after first generation.
Preventing Layout Shift from Images
Images without explicit dimensions cause CLS (Cumulative Layout Shift) when they load and push surrounding content around.
Always Set Dimensions
<!-- Bad: No dimensions, causes layout shift -->
<img src="/photo.jpg" alt="Photo" />
<!-- Good: Explicit width and height -->
<img src="/photo.jpg" alt="Photo" width="800" height="600" />
<!-- Good: CSS aspect-ratio -->
<img
src="/photo.jpg"
alt="Photo"
style="width: 100%; height: auto; aspect-ratio: 4/3;"
/>
CSS Aspect Ratio Containers
/* Reserve space for images before they load */
.image-wrapper {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #f0f0f0; /* Placeholder color */
overflow: hidden;
}
.image-wrapper img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* For older browsers without aspect-ratio support */
.image-wrapper-legacy {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 16:9 = 9/16 = 0.5625 */
}
Advanced Techniques
Responsive Background Images
/* Serve different background images based on viewport */
.hero {
background-image: url('/hero-mobile.webp');
background-size: cover;
min-height: 300px;
}
@media (min-width: 768px) {
.hero {
background-image: url('/hero-tablet.webp');
min-height: 400px;
}
}
@media (min-width: 1200px) {
.hero {
background-image: url('/hero-desktop.webp');
min-height: 600px;
}
}
/* Serve based on pixel density */
.logo {
background-image: url('/logo-1x.png');
}
@media (min-resolution: 2dppx) {
.logo {
background-image: url('/logo-2x.png');
}
}
Image Sprites for Icons
/*
Combine multiple small icons into one image request.
Reduces HTTP requests from N to 1.
*/
.icon {
background-image: url('/sprites.png');
background-repeat: no-repeat;
width: 24px;
height: 24px;
display: inline-block;
}
.icon-search { background-position: 0 0; }
.icon-home { background-position: -24px 0; }
.icon-user { background-position: -48px 0; }
.icon-mail { background-position: -72px 0; }
/*
Modern alternative: Inline SVGs or SVG sprite sheet
No HTTP requests, scalable, themeable
*/
Preloading Critical Images
<head>
<!-- Preload the LCP image so it starts loading immediately -->
<link
rel="preload"
as="image"
href="/hero.webp"
type="image/webp"
fetchpriority="high"
/>
<!-- Preload responsive image with media query -->
<link
rel="preload"
as="image"
href="/hero-mobile.webp"
type="image/webp"
media="(max-width: 768px)"
/>
<link
rel="preload"
as="image"
href="/hero-desktop.webp"
type="image/webp"
media="(min-width: 769px)"
/>
</head>
fetchpriority Attribute
<!-- High priority: LCP image, above-the-fold hero -->
<img src="/hero.jpg" alt="Hero" fetchpriority="high" />
<!-- Low priority: Below-the-fold images, thumbnails -->
<img src="/thumbnail.jpg" alt="Thumb" fetchpriority="low" loading="lazy" />
<!-- Auto (default): Browser decides -->
<img src="/content.jpg" alt="Content" />
Build Pipeline Integration
Automated Image Optimization Script
// scripts/optimize-images.js
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const INPUT_DIR = './public/images/raw';
const OUTPUT_DIR = './public/images/optimized';
const SIZES = [400, 640, 768, 1024, 1280, 1920];
const FORMATS = ['webp', 'avif'];
async function optimizeImage(inputPath) {
const basename = path.basename(inputPath, path.extname(inputPath));
const metadata = await sharp(inputPath).metadata();
for (const format of FORMATS) {
for (const width of SIZES) {
if (width > metadata.width) continue; // Skip upscaling
const outputPath = path.join(
OUTPUT_DIR,
format,
`${basename}-${width}w.${format}`
);
await sharp(inputPath)
.resize(width)
[format]({ quality: format === 'avif' ? 50 : 80 })
.toFile(outputPath);
}
}
// Also generate a JPEG fallback at original size
await sharp(inputPath)
.jpeg({ quality: 85, progressive: true })
.toFile(path.join(OUTPUT_DIR, 'jpeg', `${basename}.jpg`));
}
async function main() {
// Create output directories
for (const format of [...FORMATS, 'jpeg']) {
fs.mkdirSync(path.join(OUTPUT_DIR, format), { recursive: true });
}
const files = glob.sync(`${INPUT_DIR}/**/*.{jpg,jpeg,png}`);
console.log(`Optimizing ${files.length} images...`);
for (const file of files) {
await optimizeImage(file);
console.log(`Done: ${path.basename(file)}`);
}
console.log('All images optimized.');
}
main();
// package.json
{
"scripts": {
"images:optimize": "node scripts/optimize-images.js",
"prebuild": "npm run images:optimize"
}
}
Image Optimization Checklist
+---+-------------------------------------------+-------------+
| # | Optimization | Impact |
+---+-------------------------------------------+-------------+
| 1 | Use WebP/AVIF formats | High |
| 2 | Serve responsive sizes (srcset + sizes) | High |
| 3 | Lazy load below-the-fold images | High |
| 4 | Set width/height to prevent CLS | High |
| 5 | Preload LCP image with fetchpriority | High |
| 6 | Use a CDN for delivery | Medium-High |
| 7 | Compress with appropriate quality (75-85) | Medium |
| 8 | Use blur placeholders for perceived speed | Medium |
| 9 | Use SVG for icons, logos, illustrations | Medium |
| 10| Remove EXIF metadata | Low |
| 11| Use progressive JPEG for fallbacks | Low |
| 12| Use CSS for simple shapes (no image needed)| Low |
+---+-------------------------------------------+-------------+
Key Takeaways
- Images are 50% of average page weight — optimizing them has the highest ROI
- AVIF offers the best compression; WebP is the safe modern choice; JPEG is the universal fallback
- Use
<picture>with multiple<source>elements for format and art direction - Always set
widthandheighton images to prevent CLS - Use
loading="lazy"for below-the-fold images,fetchpriority="high"for LCP images - The
srcsetandsizesattributes let the browser pick the right image size - next/image handles format conversion, resizing, lazy loading, and caching automatically
- CDNs reduce latency and can transform images on the fly
- Automate image optimization in your build pipeline — do not rely on manual processes
- A 10x image size reduction is common when switching from unoptimized PNG/JPEG to properly sized WebP/AVIF