Steve Kinney

Image & Asset Optimization for React

Images are the silent performance killers of the modern web. That hero image on your landing page? It’s probably 2MB. Those product thumbnails? They’re loading at full resolution. The blog post illustrations? They’re in PNG when they should be WebP. Your React app might have blazing-fast JavaScript, but if you’re shipping megabytes of unoptimized images, your users are still waiting—and waiting users become former users.

The good news is that image optimization in React has never been more powerful. Modern formats like WebP and AVIF can cut file sizes by 50% or more. Responsive images ensure mobile users don’t download desktop-sized assets. Lazy loading keeps initial page loads snappy. And with the right CDN setup, your images load from servers near your users, not from across the globe. This guide shows you how to implement all of these techniques in your React applications.

Understanding Image Performance Impact

Before optimizing, understand how images affect your app’s performance:

// Image performance metrics and their impact
interface ImagePerformanceImpact {
  // Network impact
  network: {
    bandwidth: 'Images often account for 60-70% of page weight';
    requests: 'Each image is a separate HTTP request';
    blocking: 'Images can block critical resources';
    priority: 'Above-fold images compete with JavaScript';
  };

  // Rendering impact
  rendering: {
    layoutShift: 'Images without dimensions cause CLS';
    paintDelay: 'Large images delay Largest Contentful Paint';
    decoding: 'Image decoding blocks the main thread';
    memory: 'Decoded images consume significant RAM';
  };

  // User experience impact
  ux: {
    perceivedSpeed: 'Slow images make entire app feel sluggish';
    dataCost: 'Large images cost users money on mobile';
    battery: 'Image processing drains battery';
    engagement: '1 second delay = 7% conversion loss';
  };
}

Modern Image Formats and When to Use Them

Format Comparison and Selection

// utils/imageFormat.ts
export interface ImageFormat {
  extension: string;
  mimeType: string;
  support: number; // Browser support percentage
  compression: number; // Typical compression vs PNG
  useCase: string[];
  hasAlpha: boolean;
  hasAnimation: boolean;
}

export const imageFormats: Record<string, ImageFormat> = {
  webp: {
    extension: '.webp',
    mimeType: 'image/webp',
    support: 95,
    compression: 0.7, // 30% smaller than PNG
    useCase: ['photos', 'graphics', 'screenshots'],
    hasAlpha: true,
    hasAnimation: true,
  },
  avif: {
    extension: '.avif',
    mimeType: 'image/avif',
    support: 75,
    compression: 0.5, // 50% smaller than PNG
    useCase: ['photos', 'high-quality images'],
    hasAlpha: true,
    hasAnimation: true,
  },
  jpeg: {
    extension: '.jpg',
    mimeType: 'image/jpeg',
    support: 100,
    compression: 0.8,
    useCase: ['photos', 'complex images'],
    hasAlpha: false,
    hasAnimation: false,
  },
  png: {
    extension: '.png',
    mimeType: 'image/png',
    support: 100,
    compression: 1.0,
    useCase: ['graphics', 'logos', 'screenshots'],
    hasAlpha: true,
    hasAnimation: false,
  },
};

// Automatic format selection based on content
export function selectOptimalFormat(
  imageType: 'photo' | 'graphic' | 'logo',
  needsAlpha: boolean,
  browserSupport: string[],
): string {
  // Check AVIF support first (best compression)
  if (browserSupport.includes('avif') && imageType === 'photo') {
    return 'avif';
  }

  // WebP for broad support with good compression
  if (browserSupport.includes('webp')) {
    return 'webp';
  }

  // Fallback to traditional formats
  if (needsAlpha || imageType === 'logo') {
    return 'png';
  }

  return 'jpeg';
}

React Component with Format Fallbacks

// components/OptimizedImage.tsx
import { useState, useEffect, useRef } from 'react';

interface OptimizedImageProps {
  src: string;
  alt: string;
  width?: number;
  height?: number;
  sizes?: string;
  loading?: 'lazy' | 'eager';
  priority?: boolean;
  onLoad?: () => void;
  className?: string;
}

export function OptimizedImage({
  src,
  alt,
  width,
  height,
  sizes = '100vw',
  loading = 'lazy',
  priority = false,
  onLoad,
  className,
}: OptimizedImageProps) {
  const [isIntersecting, setIsIntersecting] = useState(false);
  const [hasLoaded, setHasLoaded] = useState(false);
  const imgRef = useRef<HTMLImageElement>(null);
  const pictureRef = useRef<HTMLPictureElement>(null);

  // Generate optimized source sets
  const generateSrcSet = (format: string) => {
    const basePath = src.substring(0, src.lastIndexOf('.'));
    const widths = [320, 640, 768, 1024, 1280, 1920];

    return widths.map((w) => `${basePath}-${w}w.${format} ${w}w`).join(', ');
  };

  // Intersection Observer for lazy loading
  useEffect(() => {
    if (loading === 'eager' || priority) {
      setIsIntersecting(true);
      return;
    }

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsIntersecting(true);
          observer.disconnect();
        }
      },
      {
        rootMargin: '50px',
      },
    );

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

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

  // Preload priority images
  useEffect(() => {
    if (priority && !hasLoaded) {
      const link = document.createElement('link');
      link.rel = 'preload';
      link.as = 'image';
      link.href = src;

      // Add responsive preload
      if (sizes) {
        link.sizes = sizes;
      }

      // Check WebP support for preload
      if (supportsWebP()) {
        link.type = 'image/webp';
        link.href = src.replace(/\.[^.]+$/, '.webp');
      }

      document.head.appendChild(link);

      return () => {
        document.head.removeChild(link);
      };
    }
  }, [priority, src, sizes, hasLoaded]);

  const handleLoad = () => {
    setHasLoaded(true);
    onLoad?.();
  };

  // Don't render until intersecting (for lazy loading)
  if (!isIntersecting && loading === 'lazy') {
    return (
      <div
        ref={pictureRef as any}
        className={className}
        style={{
          width,
          height,
          backgroundColor: '#f0f0f0',
        }}
        aria-label={alt}
      />
    );
  }

  return (
    <picture ref={pictureRef} className={className}>
      {/* AVIF source (best compression) */}
      <source type="image/avif" srcSet={generateSrcSet('avif')} sizes={sizes} />

      {/* WebP source (good compression, wide support) */}
      <source type="image/webp" srcSet={generateSrcSet('webp')} sizes={sizes} />

      {/* Fallback to original format */}
      <img
        ref={imgRef}
        src={src}
        alt={alt}
        width={width}
        height={height}
        loading={loading}
        decoding={priority ? 'sync' : 'async'}
        onLoad={handleLoad}
        className={className}
        style={{
          opacity: hasLoaded ? 1 : 0,
          transition: 'opacity 0.3s',
        }}
      />
    </picture>
  );
}

// Utility to check WebP support
let webpSupport: boolean | null = null;

function supportsWebP(): boolean {
  if (webpSupport !== null) return webpSupport;

  const canvas = document.createElement('canvas');
  canvas.width = 1;
  canvas.height = 1;

  webpSupport = canvas.toDataURL('image/webp').indexOf('image/webp') === 5;
  return webpSupport;
}

Responsive Images Implementation

Responsive Image Hook

// hooks/useResponsiveImage.ts
import { useState, useEffect } from 'react';

interface ResponsiveImageConfig {
  src: string;
  breakpoints?: {
    mobile?: number;
    tablet?: number;
    desktop?: number;
  };
  quality?: {
    mobile?: number;
    tablet?: number;
    desktop?: number;
  };
}

export function useResponsiveImage(config: ResponsiveImageConfig) {
  const [currentSrc, setCurrentSrc] = useState(config.src);
  const [isLoading, setIsLoading] = useState(true);

  const breakpoints = {
    mobile: 640,
    tablet: 1024,
    desktop: 1920,
    ...config.breakpoints,
  };

  const quality = {
    mobile: 70,
    tablet: 80,
    desktop: 90,
    ...config.quality,
  };

  useEffect(() => {
    const updateImageSource = () => {
      const width = window.innerWidth;
      const dpr = window.devicePixelRatio || 1;

      let breakpoint: 'mobile' | 'tablet' | 'desktop';
      let targetWidth: number;

      if (width <= breakpoints.mobile) {
        breakpoint = 'mobile';
        targetWidth = breakpoints.mobile;
      } else if (width <= breakpoints.tablet) {
        breakpoint = 'tablet';
        targetWidth = breakpoints.tablet;
      } else {
        breakpoint = 'desktop';
        targetWidth = breakpoints.desktop;
      }

      // Adjust for device pixel ratio
      targetWidth = Math.round(targetWidth * dpr);

      // Generate optimized URL
      const optimizedSrc = generateOptimizedUrl(config.src, targetWidth, quality[breakpoint]);

      setCurrentSrc(optimizedSrc);
    };

    updateImageSource();

    // Debounced resize handler
    let timeoutId: NodeJS.Timeout;
    const handleResize = () => {
      clearTimeout(timeoutId);
      timeoutId = setTimeout(updateImageSource, 300);
    };

    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
      clearTimeout(timeoutId);
    };
  }, [config.src, breakpoints, quality]);

  return {
    src: currentSrc,
    srcSet: generateSrcSet(config.src),
    sizes: generateSizes(breakpoints),
    isLoading,
    setIsLoading,
  };
}

function generateOptimizedUrl(src: string, width: number, quality: number): string {
  // If using a CDN that supports on-the-fly optimization
  const url = new URL(src);
  url.searchParams.set('w', width.toString());
  url.searchParams.set('q', quality.toString());
  url.searchParams.set('auto', 'format'); // Auto-select best format

  return url.toString();
}

function generateSrcSet(baseSrc: string): string {
  const widths = [320, 640, 768, 1024, 1280, 1920, 2560];

  return widths
    .map((w) => {
      const url = generateOptimizedUrl(baseSrc, w, 85);
      return `${url} ${w}w`;
    })
    .join(', ');
}

function generateSizes(breakpoints: any): string {
  return `
    (max-width: ${breakpoints.mobile}px) 100vw,
    (max-width: ${breakpoints.tablet}px) 50vw,
    33vw
  `.trim();
}

Art Direction with Picture Element

// components/ArtDirectedImage.tsx
interface ArtDirectedImageProps {
  sources: {
    mobile?: string;
    tablet?: string;
    desktop: string;
  };
  alt: string;
  className?: string;
}

export function ArtDirectedImage({ sources, alt, className }: ArtDirectedImageProps) {
  return (
    <picture className={className}>
      {/* Mobile-specific image (portrait crop) */}
      {sources.mobile && (
        <source
          media="(max-width: 640px)"
          srcSet={`
            ${sources.mobile.replace('.jpg', '-320w.webp')} 320w,
            ${sources.mobile.replace('.jpg', '-640w.webp')} 640w
          `}
          type="image/webp"
        />
      )}

      {/* Tablet-specific image (square crop) */}
      {sources.tablet && (
        <source
          media="(max-width: 1024px)"
          srcSet={`
            ${sources.tablet.replace('.jpg', '-768w.webp')} 768w,
            ${sources.tablet.replace('.jpg', '-1024w.webp')} 1024w
          `}
          type="image/webp"
        />
      )}

      {/* Desktop image (landscape crop) */}
      <source
        media="(min-width: 1025px)"
        srcSet={`
          ${sources.desktop.replace('.jpg', '-1280w.webp')} 1280w,
          ${sources.desktop.replace('.jpg', '-1920w.webp')} 1920w,
          ${sources.desktop.replace('.jpg', '-2560w.webp')} 2560w
        `}
        type="image/webp"
      />

      {/* Fallback for browsers without picture support */}
      <img src={sources.desktop} alt={alt} className={className} loading="lazy" decoding="async" />
    </picture>
  );
}

Advanced Lazy Loading Strategies

Progressive Image Loading

// components/ProgressiveImage.tsx
import { useState, useEffect } from 'react';

interface ProgressiveImageProps {
  placeholder: string; // Low-quality placeholder (base64 or small image)
  src: string; // Full-quality image
  alt: string;
  className?: string;
  onLoad?: () => void;
}

export function ProgressiveImage({
  placeholder,
  src,
  alt,
  className,
  onLoad,
}: ProgressiveImageProps) {
  const [imageSrc, setImageSrc] = useState(placeholder);
  const [imageRef, setImageRef] = useState<HTMLImageElement | null>(null);
  const [isLoaded, setIsLoaded] = useState(false);
  const [isInView, setIsInView] = useState(false);

  // Intersection Observer for viewport detection
  useEffect(() => {
    let observer: IntersectionObserver;

    if (imageRef) {
      observer = new IntersectionObserver(
        (entries) => {
          entries.forEach((entry) => {
            if (entry.isIntersecting) {
              setIsInView(true);
              observer.disconnect();
            }
          });
        },
        { threshold: 0.01, rootMargin: '100px' },
      );

      observer.observe(imageRef);
    }

    return () => {
      if (observer) {
        observer.disconnect();
      }
    };
  }, [imageRef]);

  // Load full image when in view
  useEffect(() => {
    if (!isInView || isLoaded) return;

    const img = new Image();

    img.onload = () => {
      setImageSrc(src);
      setIsLoaded(true);
      onLoad?.();
    };

    img.src = src;
  }, [isInView, src, isLoaded, onLoad]);

  return (
    <div className={`progressive-image ${className}`}>
      <img
        ref={setImageRef}
        src={imageSrc}
        alt={alt}
        className={` ${isLoaded ? 'loaded' : 'loading'} ${className} `}
        style={{
          filter: isLoaded ? 'none' : 'blur(5px)',
          transition: 'filter 0.3s ease-out',
        }}
      />
      {!isLoaded && <div className="loading-shimmer" />}
    </div>
  );
}

Native Lazy Loading with Fallback

// components/LazyImage.tsx
import { useRef, useEffect, useState } from 'react';

interface LazyImageProps {
  src: string;
  alt: string;
  fallback?: string;
  className?: string;
  threshold?: number;
}

export function LazyImage({
  src,
  alt,
  fallback = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="400" height="300"%3E%3C/svg%3E',
  className,
  threshold = 0.01,
}: LazyImageProps) {
  const [supportNativeLazy, setSupportNativeLazy] = useState(true);
  const [imageSrc, setImageSrc] = useState(fallback);
  const imgRef = useRef<HTMLImageElement>(null);

  useEffect(() => {
    // Check for native lazy loading support
    setSupportNativeLazy('loading' in HTMLImageElement.prototype);
  }, []);

  useEffect(() => {
    if (supportNativeLazy) {
      setImageSrc(src);
      return;
    }

    // Fallback to Intersection Observer
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setImageSrc(src);
          observer.disconnect();
        }
      },
      {
        threshold,
        rootMargin: '50px',
      },
    );

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

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

  return (
    <img
      ref={imgRef}
      src={imageSrc}
      alt={alt}
      loading={supportNativeLazy ? 'lazy' : undefined}
      className={className}
    />
  );
}

Advanced Image Optimization

For comprehensive advanced image optimization including CDN integration, build-time processing, performance monitoring, and automated optimization pipelines, see Advanced Image Optimization Techniques.

Key advanced topics covered:

  • CDN Integration: Multi-provider CDN configuration and optimization strategies
  • Build-Time Optimization: Webpack and Next.js image processing pipelines
  • Performance Monitoring: Real-time image performance metrics and analytics
  • Adaptive Loading: Network-aware loading with priority-based strategies
  • Cache Optimization: Advanced caching strategies for images and transformations

Image Optimization Checklist

interface ImageOptimizationChecklist {
  formats: {
    useModernFormats: 'Use WebP/AVIF with fallbacks';
    appropriateFormat: 'JPEG for photos, PNG for graphics';
    svg: 'Use SVG for logos and icons';
  };

  dimensions: {
    specifyDimensions: 'Always set width/height to prevent CLS';
    responsiveImages: 'Provide multiple sizes via srcset';
    artDirection: 'Use picture element for different crops';
  };

  loading: {
    lazyLoad: 'Lazy load below-fold images';
    priorityLoad: 'Preload above-fold critical images';
    progressive: 'Use progressive enhancement';
  };

  optimization: {
    compress: 'Compress images (85% quality usually sufficient)';
    rightSize: 'Serve correctly sized images for viewport';
    cdn: 'Serve images from CDN';
  };

  performance: {
    measure: 'Track image loading metrics';
    budget: 'Set and enforce image size budgets';
    monitor: 'Monitor Core Web Vitals impact';
  };
}

Last modified on .