TechLead
Lesson 5 of 22
5 min read
Performance Engineering

Image Optimization

Master modern image formats, responsive images, lazy loading, and Next.js Image component for optimal delivery

Why Image Optimization Matters

Images are typically the largest assets on a web page, accounting for 40-60% of total page weight. Poorly optimized images are the single biggest cause of slow LCP scores. Modern image optimization involves choosing the right format, serving responsive sizes, lazy loading offscreen images, and using CDN-based image processing.

Modern Image Formats Compared

  • JPEG: Good for photographs. 75-85% quality is the sweet spot. No transparency.
  • PNG: Lossless, supports transparency. Large file sizes. Use only when transparency is needed.
  • WebP: 25-35% smaller than JPEG at equivalent quality. Supports transparency and animation. 97% browser support.
  • AVIF: 50% smaller than JPEG. Best compression available. 92% browser support. Slower to encode.
  • SVG: Vector format for icons, logos, illustrations. Infinitely scalable, very small file size.

Responsive Images

Serving a single large image to all devices wastes bandwidth on mobile. Responsive images use the srcset and sizes attributes to let the browser choose the optimal image size based on the viewport width and device pixel ratio.

// Native HTML responsive images
// <img
//   srcset="
//     /images/hero-400.webp 400w,
//     /images/hero-800.webp 800w,
//     /images/hero-1200.webp 1200w,
//     /images/hero-1600.webp 1600w
//   "
//   sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 1200px"
//   src="/images/hero-1200.webp"
//   alt="Hero image"
//   width="1200"
//   height="600"
//   loading="lazy"
//   decoding="async"
//   fetchpriority="high"
// />

// Next.js Image component — handles all of this automatically
import Image from 'next/image';

// For LCP images: use priority to preload
function HeroImage() {
  return (
    <Image
      src="/images/hero.jpg"
      alt="Hero banner"
      width={1200}
      height={600}
      priority        // Preloads image, removes lazy loading
      sizes="100vw"   // Full viewport width
      quality={85}    // Compression quality
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
    />
  );
}

// For below-fold images: default lazy loading
function ProductCard({ product }: { product: { image: string; name: string } }) {
  return (
    <Image
      src={product.image}
      alt={product.name}
      width={400}
      height={300}
      sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
      quality={80}
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
    />
  );
}

Image Processing Pipeline

// Build-time image optimization with Sharp
import sharp from 'sharp';
import * as fs from 'fs';
import * as path from 'path';

interface ImageConfig {
  widths: number[];
  formats: ('webp' | 'avif' | 'jpeg')[];
  quality: Record<string, number>;
}

const config: ImageConfig = {
  widths: [400, 800, 1200, 1600],
  formats: ['avif', 'webp', 'jpeg'],
  quality: { avif: 60, webp: 80, jpeg: 85 },
};

async function optimizeImage(inputPath: string, outputDir: string): Promise<void> {
  const filename = path.basename(inputPath, path.extname(inputPath));

  for (const width of config.widths) {
    for (const format of config.formats) {
      const outputPath = path.join(outputDir, `${filename}-${width}.${format}`);
      const pipeline = sharp(inputPath).resize(width, null, {
        withoutEnlargement: true,
        fit: 'inside',
      });

      switch (format) {
        case 'avif':
          await pipeline.avif({ quality: config.quality.avif }).toFile(outputPath);
          break;
        case 'webp':
          await pipeline.webp({ quality: config.quality.webp }).toFile(outputPath);
          break;
        case 'jpeg':
          await pipeline.jpeg({ quality: config.quality.jpeg, progressive: true }).toFile(outputPath);
          break;
      }

      const stats = fs.statSync(outputPath);
      console.log(`Generated: ${outputPath} (${(stats.size / 1024).toFixed(1)}KB)`);
    }
  }
}

// Generate blur placeholder
async function generateBlurPlaceholder(inputPath: string): Promise<string> {
  const buffer = await sharp(inputPath)
    .resize(10, 10, { fit: 'inside' })
    .jpeg({ quality: 30 })
    .toBuffer();

  return `data:image/jpeg;base64,${buffer.toString('base64')}`;
}

// Process all images in a directory
async function processAllImages(inputDir: string, outputDir: string): Promise<void> {
  fs.mkdirSync(outputDir, { recursive: true });
  const files = fs.readdirSync(inputDir).filter(f =>
    ['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(f).toLowerCase())
  );

  for (const file of files) {
    console.log(`Processing: ${file}`);
    await optimizeImage(path.join(inputDir, file), outputDir);
  }
}

Lazy Loading and Intersection Observer

// Custom lazy loading with Intersection Observer
function useLazyImage(threshold: number = 0.1) {
  const [isVisible, setIsVisible] = useState(false);
  const imgRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.disconnect();
        }
      },
      {
        rootMargin: '200px',  // Start loading 200px before viewport
        threshold,
      }
    );

    if (imgRef.current) {
      observer.observe(imgRef.current);
    }

    return () => observer.disconnect();
  }, [threshold]);

  return { imgRef, isVisible };
}

// Usage in a component
function LazyImage({ src, alt }: { src: string; alt: string }) {
  const { imgRef, isVisible } = useLazyImage();

  return (
    <div ref={imgRef} style={{ aspectRatio: '16/9', background: '#f0f0f0' }}>
      {isVisible && (
        <img src={src} alt={alt} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
      )}
    </div>
  );
}

Image Optimization Checklist

  • Use modern formats: Serve AVIF with WebP fallback for maximum compression
  • Serve responsive sizes: Never send a 2000px image to a 400px viewport
  • Prioritize LCP images: Use fetchpriority="high" and preload for hero images
  • Lazy load offscreen: Use loading="lazy" for images below the fold
  • Use blur placeholders: Show a low-quality placeholder while the full image loads
  • Set dimensions: Always include width and height to prevent CLS

Continue Learning