Image Optimization
Images are the largest category of bytes shipped on the web — typically 50% of total page weight. Optimizing images is one of the highest-impact performance improvements you can make. The goal is to serve the smallest possible image at the highest acceptable quality for each user's device and viewport.
Image optimization encompasses format selection (JPEG, PNG, WebP, AVIF), responsive sizing (srcset, sizes), compression (lossy vs lossless), delivery strategy (CDN, lazy loading), and art direction (cropping for different viewports). Each dimension affects both visual quality and performance.
Choosing the right image format is the first and most impactful decision. Modern formats like WebP and AVIF offer significantly better compression than legacy formats, often reducing file sizes by 30-50% with equivalent quality.
| Format | Type | Compression | Transparency | Animation | Support | Best For |
|---|---|---|---|---|---|---|
| JPEG | Raster | Lossy | No | No | 100% | Photos (legacy fallback) |
| PNG | Raster | Lossless | Yes | No | 100% | Screenshots, logos (legacy) |
| WebP | Raster | Lossy + Lossless | Yes | Yes | 97% | General purpose (default choice) |
| AVIF | Raster | Lossy + Lossless | Yes | Yes | 88% | Best compression (photos) |
| SVG | Vector | N/A (XML) | Yes | Yes | 100% | Icons, logos, illustrations |
| GIF | Raster | Lossless (limited) | Binary only | Yes | 100% | Legacy animations (use video) |
| 1 | // Converting images to modern formats with sharp (Node.js) |
| 2 | import sharp from "sharp"; |
| 3 | |
| 4 | // Convert JPEG to WebP (lossy, quality 80) |
| 5 | await sharp("input.jpg") |
| 6 | .webp({ quality: 80, effort: 4 }) |
| 7 | .toFile("output.webp"); |
| 8 | |
| 9 | // Convert to AVIF (even better compression) |
| 10 | await sharp("input.jpg") |
| 11 | .avif({ quality: 65, effort: 4 }) |
| 12 | .toFile("output.avif"); |
| 13 | |
| 14 | // Generate multiple formats and sizes for picture element |
| 15 | async function generateImageVariants( |
| 16 | inputPath: string, |
| 17 | outputDir: string, |
| 18 | name: string |
| 19 | ) { |
| 20 | const sizes = [640, 1280, 1920]; |
| 21 | |
| 22 | for (const width of sizes) { |
| 23 | // WebP |
| 24 | await sharp(inputPath) |
| 25 | .resize(width) |
| 26 | .webp({ quality: 80 }) |
| 27 | .toFile(outputDir + "/" + name + "-" + width + "w.webp"); |
| 28 | |
| 29 | // AVIF |
| 30 | await sharp(inputPath) |
| 31 | .resize(width) |
| 32 | .avif({ quality: 65 }) |
| 33 | .toFile(outputDir + "/" + name + "-" + width + "w.avif"); |
| 34 | |
| 35 | // JPEG fallback |
| 36 | await sharp(inputPath) |
| 37 | .resize(width) |
| 38 | .jpeg({ quality: 80, mozjpeg: true }) |
| 39 | .toFile(outputDir + "/" + name + "-" + width + "w.jpg"); |
| 40 | } |
| 41 | |
| 42 | // Low-quality placeholder (LQIP) — 20px wide |
| 43 | const lqip = await sharp(inputPath) |
| 44 | .resize(20) |
| 45 | .webp({ quality: 20 }) |
| 46 | .toBuffer(); |
| 47 | |
| 48 | const lqipBase64 = "data:image/webp;base64," + lqip.toString("base64"); |
| 49 | return { lqipBase64 }; |
| 50 | } |
| 51 | |
| 52 | // Get image metadata |
| 53 | const metadata = await sharp("photo.jpg").metadata(); |
| 54 | console.log("Original:", metadata.width + "x" + metadata.height, |
| 55 | metadata.format); |
pro tip
The <picture> element enables format negotiation — the browser selects the best format it supports from multiple sources. This allows you to serve AVIF to supporting browsers, WebP to most, and JPEG as a fallback.
| 1 | <!-- Format negotiation with picture element --> |
| 2 | <picture> |
| 3 | <!-- AVIF — best compression, newest format --> |
| 4 | <source |
| 5 | type="image/avif" |
| 6 | srcset="photo-640w.avif 640w, photo-1280w.avif 1280w, photo-1920w.avif 1920w" |
| 7 | sizes="(max-width: 640px) 100vw, (max-width: 1280px) 50vw, 33vw" |
| 8 | /> |
| 9 | |
| 10 | <!-- WebP — excellent compression, wide support --> |
| 11 | <source |
| 12 | type="image/webp" |
| 13 | srcset="photo-640w.webp 640w, photo-1280w.webp 1280w, photo-1920w.webp 1920w" |
| 14 | sizes="(max-width: 640px) 100vw, (max-width: 1280px) 50vw, 33vw" |
| 15 | /> |
| 16 | |
| 17 | <!-- JPEG fallback — universal support --> |
| 18 | <img |
| 19 | src="photo-1280w.jpg" |
| 20 | alt="A scenic mountain landscape" |
| 21 | width="1920" |
| 22 | height="1080" |
| 23 | loading="lazy" |
| 24 | decoding="async" |
| 25 | /> |
| 26 | </picture> |
| 27 | |
| 28 | <!-- Art direction — different crops for different viewports --> |
| 29 | <picture> |
| 30 | <source |
| 31 | media="(max-width: 640px)" |
| 32 | srcset="hero-mobile.avif 1x, hero-mobile@2x.avif 2x" |
| 33 | type="image/avif" |
| 34 | /> |
| 35 | <source |
| 36 | media="(max-width: 640px)" |
| 37 | srcset="hero-mobile.webp 1x, hero-mobile@2x.webp 2x" |
| 38 | type="image/webp" |
| 39 | /> |
| 40 | <source |
| 41 | media="(max-width: 640px)" |
| 42 | srcset="hero-mobile.jpg 1x, hero-mobile@2x.jpg 2x" |
| 43 | /> |
| 44 | <source |
| 45 | type="image/avif" |
| 46 | srcset="hero-desktop.avif 1x, hero-desktop@2x.avif 2x" |
| 47 | /> |
| 48 | <source |
| 49 | type="image/webp" |
| 50 | srcset="hero-desktop.webp 1x, hero-desktop@2x.webp 2x" |
| 51 | /> |
| 52 | <img |
| 53 | src="hero-desktop.jpg" |
| 54 | alt="Hero banner" |
| 55 | width="1920" |
| 56 | height="800" |
| 57 | loading="eager" |
| 58 | fetchpriority="high" |
| 59 | /> |
| 60 | </picture> |
info
The srcset and sizes attributes let the browser choose the optimal image size based on viewport width and device pixel ratio. This prevents serving a 1920px image to a 320px phone screen.
| 1 | <!-- srcset with width descriptors (recommended) --> |
| 2 | <img |
| 3 | src="photo-800w.jpg" |
| 4 | srcset="photo-400w.jpg 400w, |
| 5 | photo-800w.jpg 800w, |
| 6 | photo-1200w.jpg 1200w, |
| 7 | photo-1920w.jpg 1920w" |
| 8 | sizes="(max-width: 640px) 100vw, |
| 9 | (max-width: 1024px) 50vw, |
| 10 | 33vw" |
| 11 | alt="Responsive image example" |
| 12 | width="1920" |
| 13 | height="1080" |
| 14 | loading="lazy" |
| 15 | /> |
| 16 | |
| 17 | <!-- How sizes works: |
| 18 | 1. Browser checks each media condition in sizes (left to right) |
| 19 | 2. Uses the first matching condition's value |
| 20 | 3. Multiplies by device pixel ratio (DPR) to pick the best srcset candidate |
| 21 | |
| 22 | Example on 2x iPhone (375px viewport): |
| 23 | - Media: (max-width: 640px) matches -> 100vw = 375px |
| 24 | - DPR 2x: needs 750px image -> picks photo-800w.jpg |
| 25 | --> |
| 26 | |
| 27 | <!-- High-DPI (Retina) support --> |
| 28 | <img |
| 29 | srcset="photo-400w.jpg 400w, |
| 30 | photo-800w.jpg 800w, |
| 31 | photo-1600w.jpg 1600w" |
| 32 | sizes="(max-width: 640px) 100vw, 50vw" |
| 33 | alt="Retina-ready image" |
| 34 | width="1600" |
| 35 | height="900" |
| 36 | /> |
| 37 | |
| 38 | <!-- Fixed-size images (no sizes attribute needed) --> |
| 39 | <img |
| 40 | srcset="avatar-1x.jpg 1x, avatar-2x.jpg 2x, avatar-3x.jpg 3x" |
| 41 | src="avatar-1x.jpg" |
| 42 | alt="User avatar" |
| 43 | width="48" |
| 44 | height="48" |
| 45 | /> |
| 46 | |
| 47 | <!-- Intrinsic sizing (prevents layout shift) --> |
| 48 | <img |
| 49 | srcset="photo-400w.jpg 400w, photo-800w.jpg 800w" |
| 50 | sizes="(max-width: 640px) 100vw, 400px" |
| 51 | alt="Product image" |
| 52 | width="400" |
| 53 | height="300" |
| 54 | style={{ aspectRatio: "4/3" }} |
| 55 | /> |
| 1 | // React responsive image component |
| 2 | function ResponsiveImage({ |
| 3 | src, // Base filename without extension/suffix |
| 4 | alt, |
| 5 | width, |
| 6 | height, |
| 7 | sizes = "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw", |
| 8 | className = "", |
| 9 | loading = "lazy", |
| 10 | }: { |
| 11 | src: string; |
| 12 | alt: string; |
| 13 | width: number; |
| 14 | height: number; |
| 15 | sizes?: string; |
| 16 | className?: string; |
| 17 | loading?: "lazy" | "eager"; |
| 18 | }) { |
| 19 | const breakpoints = [400, 800, 1200, 1920]; |
| 20 | |
| 21 | const srcSet = (ext: string) => |
| 22 | breakpoints |
| 23 | .filter((bp) => bp <= width * 2) |
| 24 | .map((bp) => src + "-" + bp + "w." + ext + " " + bp + "w") |
| 25 | .join(", "); |
| 26 | |
| 27 | return ( |
| 28 | <picture> |
| 29 | <source |
| 30 | type="image/avif" |
| 31 | srcSet={srcSet("avif")} |
| 32 | sizes={sizes} |
| 33 | /> |
| 34 | <source |
| 35 | type="image/webp" |
| 36 | srcSet={srcSet("webp")} |
| 37 | sizes={sizes} |
| 38 | /> |
| 39 | <img |
| 40 | src={src + "-800w.jpg"} |
| 41 | srcSet={srcSet("jpg")} |
| 42 | sizes={sizes} |
| 43 | alt={alt} |
| 44 | width={width} |
| 45 | height={height} |
| 46 | loading={loading} |
| 47 | decoding="async" |
| 48 | className={className} |
| 49 | /> |
| 50 | </picture> |
| 51 | ); |
| 52 | } |
| 53 | |
| 54 | // Usage |
| 55 | // <ResponsiveImage |
| 56 | // src="/images/hero" |
| 57 | // alt="Hero banner" |
| 58 | // width={1920} |
| 59 | // height={800} |
| 60 | // loading="eager" |
| 61 | // /> |
| 62 | // |
| 63 | // <ResponsiveImage |
| 64 | // src="/images/product-123" |
| 65 | // alt="Product photo" |
| 66 | // width={800} |
| 67 | // height={600} |
| 68 | // sizes="(max-width: 768px) 100vw, 400px" |
| 69 | // /> |
warning
Art direction means showing different image crops for different viewports. A wide landscape photo on desktop may need to be a close-up portrait on mobile. The <picture> element with media attributes enables this.
| 1 | <!-- Art direction: different crops for mobile vs desktop --> |
| 2 | <picture> |
| 3 | <!-- Mobile: vertical crop (640x800) --> |
| 4 | <source |
| 5 | media="(max-width: 768px)" |
| 6 | srcset="hero-mobile-400.avif 400w, |
| 7 | hero-mobile-800.avif 800w" |
| 8 | sizes="100vw" |
| 9 | type="image/avif" |
| 10 | /> |
| 11 | <source |
| 12 | media="(max-width: 768px)" |
| 13 | srcset="hero-mobile-400.webp 400w, |
| 14 | hero-mobile-800.webp 800w" |
| 15 | sizes="100vw" |
| 16 | type="image/webp" |
| 17 | /> |
| 18 | |
| 19 | <!-- Tablet: square crop (800x800) --> |
| 20 | <source |
| 21 | media="(max-width: 1024px)" |
| 22 | srcset="hero-tablet-800.avif 800w, |
| 23 | hero-tablet-1200.avif 1200w" |
| 24 | sizes="100vw" |
| 25 | type="image/avif" |
| 26 | /> |
| 27 | |
| 28 | <!-- Desktop: wide landscape (1920x800) --> |
| 29 | <source |
| 30 | type="image/avif" |
| 31 | srcset="hero-desktop-1200.avif 1200w, |
| 32 | hero-desktop-1920.avif 1920w" |
| 33 | sizes="100vw" |
| 34 | /> |
| 35 | <source |
| 36 | type="image/webp" |
| 37 | srcset="hero-desktop-1200.webp 1200w, |
| 38 | hero-desktop-1920.webp 1920w" |
| 39 | sizes="100vw" |
| 40 | /> |
| 41 | |
| 42 | <!-- Fallback --> |
| 43 | <img |
| 44 | src="hero-desktop-1920.jpg" |
| 45 | alt="Product hero showing key features" |
| 46 | width="1920" |
| 47 | height="800" |
| 48 | loading="eager" |
| 49 | fetchpriority="high" |
| 50 | /> |
| 51 | </picture> |
| 52 | |
| 53 | <!-- Object-fit for CSS-controlled art direction --> |
| 54 | <style> |
| 55 | .hero-image { |
| 56 | width: 100%; |
| 57 | height: 50vh; |
| 58 | object-fit: cover; |
| 59 | object-position: center 30%; /* Focus on upper third */ |
| 60 | } |
| 61 | |
| 62 | @media (max-width: 768px) { |
| 63 | .hero-image { |
| 64 | height: 70vh; |
| 65 | object-position: center 50%; /* Center crop on mobile */ |
| 66 | } |
| 67 | } |
| 68 | </style> |
| 69 | |
| 70 | <img |
| 71 | src="hero-wide.jpg" |
| 72 | alt="Hero image" |
| 73 | class="hero-image" |
| 74 | width="1920" |
| 75 | height="800" |
| 76 | loading="eager" |
| 77 | /> |
best practice
Image CDNs (Cloudinary, Imgix, Cloudflare Images, Vercel) handle format conversion, resizing, compression, and delivery automatically. They can generate optimized variants on-the-fly from a single source image, eliminating the need for build-time processing.
| 1 | // Cloudinary — on-the-fly transformations via URL |
| 2 | // Base: https://res.cloudinary.com/demo/image/upload/sample.jpg |
| 3 | |
| 4 | const cloudinaryUrl = (publicId: string, options: { |
| 5 | width?: number; |
| 6 | height?: number; |
| 7 | format?: "auto" | "webp" | "avif"; |
| 8 | quality?: number; |
| 9 | crop?: "fill" | "fit" | "scale" | "thumb"; |
| 10 | }) => { |
| 11 | const params = [ |
| 12 | options.width && "w_" + options.width, |
| 13 | options.height && "h_" + options.height, |
| 14 | options.format && "f_" + options.format, |
| 15 | options.quality && "q_" + options.quality, |
| 16 | options.crop && "c_" + options.crop, |
| 17 | ].filter(Boolean).join(","); |
| 18 | |
| 19 | return "https://res.cloudinary.com/demo/image/upload/" + params + "/" + publicId; |
| 20 | }; |
| 21 | |
| 22 | // Usage: |
| 23 | // Auto format + quality: /upload/f_auto,q_auto/sample.jpg |
| 24 | // Specific size: /upload/w_800,h_600,c_fill,f_auto,q_auto/sample.jpg |
| 25 | |
| 26 | // Imgix — similar URL-based transformations |
| 27 | const imgixUrl = (src: string, params: Record<string, any>) => { |
| 28 | const qs = new URLSearchParams(params).toString(); |
| 29 | return "https://images.example.com/" + src + "?" + qs; |
| 30 | }; |
| 31 | |
| 32 | // Usage: |
| 33 | // imgixUrl("photo.jpg", { w: 800, h: 600, fit: "crop", fm: "avif", q: 80 }) |
| 34 | // => https://images.example.com/photo.jpg?w=800&h=600&fit=crop&fm=avif&q=80 |
| 35 | |
| 36 | // Next.js built-in image optimization |
| 37 | import Image from "next/image"; |
| 38 | |
| 39 | <Image |
| 40 | src="/photos/hero.jpg" |
| 41 | alt="Hero" |
| 42 | width={1920} |
| 43 | height={800} |
| 44 | priority // For above-the-fold images (eager load) |
| 45 | placeholder="blur" |
| 46 | blurDataURL="data:image/jpeg;base64,/9j/4AAQ..." |
| 47 | sizes="(max-width: 768px) 100vw, 50vw" |
| 48 | /> |
| 49 | |
| 50 | // next.config.js — configure image domains and formats |
| 51 | // images: { |
| 52 | // formats: ["image/avif", "image/webp"], |
| 53 | // deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048], |
| 54 | // imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], |
| 55 | // remotePatterns: [ |
| 56 | // { protocol: "https", hostname: "images.example.com" }, |
| 57 | // ], |
| 58 | // } |
| CDN | Free Tier | Format Conversion | On-the-fly Resize | Best For |
|---|---|---|---|---|
| Cloudinary | 25K transforms/mo | Auto (f_auto) | Yes | Full-featured, media libraries |
| Imgix | 1000 origin images | Yes (fm=auto) | Yes | Real-time transformations |
| Cloudflare Images | $0.50/100K | Auto | Yes | Edge delivery, low latency |
| Vercel | Included in plan | Auto | Via next/image | Next.js projects |
| Self-hosted (sharp) | Free | Build-time only | No | Full control, no vendor lock-in |
info
Compression reduces image file size by removing unnecessary data. Lossy compression discards some visual information (imperceptible at reasonable quality levels). Lossless compression preserves all data but achieves smaller reductions.
| 1 | // Compression quality guidelines by image type |
| 2 | const compressionSettings = { |
| 3 | // Photographs (people, landscapes, products) |
| 4 | photo: { |
| 5 | webp: { quality: 75, effort: 4 }, // ~30% smaller than JPEG |
| 6 | avif: { quality: 55, effort: 6 }, // ~50% smaller than JPEG |
| 7 | jpeg: { quality: 78, mozjpeg: true }, // MozJPEG optimized encoding |
| 8 | }, |
| 9 | |
| 10 | // Screenshots, UI elements, text-heavy images |
| 11 | screenshot: { |
| 12 | webp: { quality: 85, effort: 4 }, // Higher quality for text clarity |
| 13 | avif: { quality: 70, effort: 6 }, |
| 14 | png: { compressionLevel: 9 }, // Lossless for pixel-perfect |
| 15 | }, |
| 16 | |
| 17 | // Thumbnails (small previews) |
| 18 | thumbnail: { |
| 19 | webp: { quality: 60, effort: 6 }, // Smaller = faster loading |
| 20 | avif: { quality: 45, effort: 6 }, |
| 21 | jpeg: { quality: 65, mozjpeg: true }, |
| 22 | }, |
| 23 | |
| 24 | // Illustrations, flat graphics with few colors |
| 25 | illustration: { |
| 26 | webp: { quality: 80, effort: 4 }, |
| 27 | avif: { quality: 65, effort: 6 }, |
| 28 | png: { compressionLevel: 9 }, // Lossless for crisp edges |
| 29 | }, |
| 30 | }; |
| 31 | |
| 32 | // Analyze image for optimal compression |
| 33 | async function analyzeImageQuality(inputPath: string) { |
| 34 | const image = sharp(inputPath); |
| 35 | const metadata = await image.metadata(); |
| 36 | const stats = await image.stats(); |
| 37 | |
| 38 | console.log("Image analysis:"); |
| 39 | console.log(" Format:", metadata.format); |
| 40 | console.log(" Dimensions:", metadata.width + "x" + metadata.height); |
| 41 | console.log(" Channels:", metadata.channels); |
| 42 | console.log(" Has alpha:", metadata.hasAlpha); |
| 43 | console.log(" Entropy:", stats.entropy.toFixed(2)); // Higher = more complex |
| 44 | console.log(" Sharpness:", stats.sharpness.toFixed(2)); |
| 45 | |
| 46 | // Higher entropy = needs more quality to look good |
| 47 | const suggestedQuality = stats.entropy > 7.5 ? 80 : 70; |
| 48 | return { suggestedQuality, metadata, stats }; |
| 49 | } |
Track these metrics to measure the impact of your image optimizations. Both Lighthouse and custom performance observers can provide these measurements.
| 1 | // Measure image loading performance |
| 2 | function measureImagePerformance() { |
| 3 | const images = performance.getEntriesByType("resource") |
| 4 | .filter((r) => r.initiatorType === "img") as PerformanceResourceTiming[]; |
| 5 | |
| 6 | const stats = images.map((img) => { |
| 7 | const url = new URL(img.name); |
| 8 | const filename = url.pathname.split("/").pop() || ""; |
| 9 | const ext = filename.split(".").pop()?.split("?")[0] || ""; |
| 10 | |
| 11 | return { |
| 12 | filename, |
| 13 | format: ext, |
| 14 | transferSize: img.transferSize, |
| 15 | decodedBodySize: img.decodedBodySize, |
| 16 | duration: img.duration, |
| 17 | ttfb: img.responseStart - img.requestStart, |
| 18 | downloadTime: img.responseEnd - img.responseStart, |
| 19 | cached: img.transferSize === 0, |
| 20 | }; |
| 21 | }); |
| 22 | |
| 23 | console.table(stats); |
| 24 | |
| 25 | // Summary |
| 26 | const totalTransfer = stats.reduce((s, i) => s + i.transferSize, 0); |
| 27 | const cachedCount = stats.filter((i) => i.cached).length; |
| 28 | const avgDuration = stats.reduce((s, i) => s + i.duration, 0) / stats.length; |
| 29 | |
| 30 | console.log("Total image transfer:", (totalTransfer / 1024).toFixed(1) + "KB"); |
| 31 | console.log("Cached:", cachedCount + "/" + stats.length); |
| 32 | console.log("Avg load time:", avgDuration.toFixed(0) + "ms"); |
| 33 | |
| 34 | // Check for oversized images |
| 35 | const oversized = stats.filter((i) => i.transferSize > 200 * 1024); |
| 36 | if (oversized.length > 0) { |
| 37 | console.warn("Oversized images (>200KB):", oversized.map((i) => i.filename)); |
| 38 | } |
| 39 | |
| 40 | // Check for non-modern formats |
| 41 | const legacyFormats = stats.filter((i) => |
| 42 | ["jpg", "jpeg", "png", "gif"].includes(i.format) && !i.cached |
| 43 | ); |
| 44 | if (legacyFormats.length > 0) { |
| 45 | console.warn("Legacy formats (convert to WebP/AVIF):", |
| 46 | legacyFormats.map((i) => i.filename)); |
| 47 | } |
| 48 | } |