|$ curl https://forge-ai.dev/api/markdown?path=docs/performance/images
$cat docs/image-optimization.md
updated Recently·40 min read·published

Image Optimization

PerformanceWebPAVIFResponsive ImagesCDNIntermediate🎯Free Tools
Introduction

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.

Image Formats

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.

FormatTypeCompressionTransparencyAnimationSupportBest For
JPEGRasterLossyNoNo100%Photos (legacy fallback)
PNGRasterLosslessYesNo100%Screenshots, logos (legacy)
WebPRasterLossy + LosslessYesYes97%General purpose (default choice)
AVIFRasterLossy + LosslessYesYes88%Best compression (photos)
SVGVectorN/A (XML)YesYes100%Icons, logos, illustrations
GIFRasterLossless (limited)Binary onlyYes100%Legacy animations (use video)
image-conversion.js
TypeScript
1// Converting images to modern formats with sharp (Node.js)
2import sharp from "sharp";
3
4// Convert JPEG to WebP (lossy, quality 80)
5await sharp("input.jpg")
6 .webp({ quality: 80, effort: 4 })
7 .toFile("output.webp");
8
9// Convert to AVIF (even better compression)
10await sharp("input.jpg")
11 .avif({ quality: 65, effort: 4 })
12 .toFile("output.avif");
13
14// Generate multiple formats and sizes for picture element
15async 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
53const metadata = await sharp("photo.jpg").metadata();
54console.log("Original:", metadata.width + "x" + metadata.height,
55 metadata.format);
🔥

pro tip

Use sharp (Node.js) or libvips for server-side image processing. For build-time optimization, use next/image, vite-imagetools, or @parcel/transformer-image. Never ship unoptimized images to production.
The picture Element & Format Negotiation

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.

picture-element.html
HTML
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 <picture> element with multiple <source> elements provides both format negotiation and responsive sizing in a single HTML construct. Always include a JPEG/PNG fallback inside the <img>tag for browsers that don't support the picture element.
Responsive Images with srcset

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.

responsive-images.html
HTML
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/>
responsive-image-component.tsx
TypeScript
1// React responsive image component
2function 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

Always set explicit width and height attributes on responsive images. Without them, the browser cannot reserve space before the image loads, causing layout shift (CLS). The browser uses these attributes to calculate the aspect ratio.
Art Direction

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.

art-direction.html
HTML
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

Use the <picture> element with media queries for art direction when you need different image crops. Use srcset with sizes for resolution switching (same crop, different sizes). These serve different purposes and are often confused.
Image CDN Delivery

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.

image-cdn.ts
TypeScript
1// Cloudinary — on-the-fly transformations via URL
2// Base: https://res.cloudinary.com/demo/image/upload/sample.jpg
3
4const 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
27const 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
37import 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// }
CDNFree TierFormat ConversionOn-the-fly ResizeBest For
Cloudinary25K transforms/moAuto (f_auto)YesFull-featured, media libraries
Imgix1000 origin imagesYes (fm=auto)YesReal-time transformations
Cloudflare Images$0.50/100KAutoYesEdge delivery, low latency
VercelIncluded in planAutoVia next/imageNext.js projects
Self-hosted (sharp)FreeBuild-time onlyNoFull control, no vendor lock-in

info

Image CDNs are almost always worth the cost for production applications. They eliminate build complexity, handle format negotiation automatically, and serve images from edge locations worldwide. A single f_auto parameter can reduce image transfer size by 30-50%.
Compression Strategies

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.

compression-strategies.ts
TypeScript
1// Compression quality guidelines by image type
2const 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
33async 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}
Measuring Image Performance

Track these metrics to measure the impact of your image optimizations. Both Lighthouse and custom performance observers can provide these measurements.

image-metrics.ts
TypeScript
1// Measure image loading performance
2function 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}
Best Practices
Use WebP as the default format — 97% browser support, 25-35% smaller than JPEG
Use AVIF for maximum compression — 50% smaller than JPEG, growing browser support
Always provide a JPEG/PNG fallback in the picture element
Use srcset with sizes for responsive sizing — never serve one-size-fits-all images
Set explicit width and height on all images to prevent layout shift
Use lazy loading for below-the-fold images (loading='lazy')
Use eager loading and fetchpriority='high' for LCP candidates
Implement art direction with picture media queries for mobile-optimized crops
Use an image CDN for on-the-fly format conversion and resizing
Compress images to the lowest quality that looks acceptable — quality 75-80 for photos
$Blueprint — Engineering Documentation·Section ID: PERF-IMG-01·Revision: 1.0