Skip to main content
fast frontend

Responsive Images and Art Direction

6 min read Chapter 11 of 33

Responsive Images and Art Direction

The Symptom

The e-commerce product listing page serves a 1200x800 product image to all devices. On a mobile phone with a 375px-wide viewport displaying the image at 187px (50% of viewport in a two-column grid), the browser downloads 180KB for an image displayed at 187px. A 400px-wide version of the same image would be 28KB. The user downloads 152KB of pixels they never see.

Across 48 products on a listing page, this waste compounds to 7.3MB of unnecessary image data. On a 4G connection, that is 36 seconds of download time that produces zero visual benefit.

The Cause

Without srcset and sizes, the browser has one image URL and downloads it at its full resolution. The browser cannot know how large the image will be displayed until CSS is parsed and layout is computed, but by then the image download has already started (or should have started, for performance). The srcset and sizes attributes provide the information the browser needs to select the right image variant before layout, during HTML parsing.

The srcset attribute lists available image variants with their widths:

<img
  srcset="
    product-400w.avif   400w,
    product-800w.avif   800w,
    product-1200w.avif 1200w
  "
  sizes="(max-width: 640px) 50vw,
            (max-width: 1024px) 33vw,
            25vw"
  src="product-800w.avif"
  alt="Product"
  width="400"
  height="400"
/>

The browser evaluates sizes against the viewport width, determines the display size in CSS pixels, multiplies by the device pixel ratio, and selects the smallest srcset candidate that covers the result.

On a 375px phone with 2x DPR:

  • sizes evaluates to 50vw = 187.5px
  • Display pixels needed: 187.5 * 2 = 375px
  • Browser selects product-400w.avif (400px, smallest that covers 375px)
  • Download: 28KB instead of 180KB

On a 1440px desktop with 1x DPR:

  • sizes evaluates to 25vw = 360px
  • Display pixels needed: 360 * 1 = 360px
  • Browser selects product-400w.avif
  • Download: 28KB

On a 1440px Retina desktop with 2x DPR:

  • sizes evaluates to 25vw = 360px
  • Display pixels needed: 360 * 2 = 720px
  • Browser selects product-800w.avif
  • Download: 62KB

The Baseline

Product listing page image payload by device class:

DeviceImages (no srcset)Images (with srcset)Saving
Mobile (375px, 2x)8.6 MB1.3 MB-85%
Tablet (768px, 2x)8.6 MB2.8 MB-67%
Desktop (1440px, 1x)8.6 MB1.3 MB-85%
Desktop (1440px, 2x)8.6 MB3.0 MB-65%

The Fix

A TypeScript component that generates the full responsive image markup:

interface ImageVariant {
  width: number;
  format: 'avif' | 'webp' | 'jpg';
}

interface ResponsiveImageConfig {
  basePath: string;
  alt: string;
  widths: number[];
  sizes: string;
  aspectRatio: number;
  isLCP: boolean;
}

function generateSrcSet(
  basePath: string,
  widths: number[],
  format: string
): string {
  return widths
    .map((w) => `${basePath}-${w}w.${format} ${w}w`)
    .join(', ');
}

function ResponsiveImage({
  basePath,
  alt,
  widths,
  sizes,
  aspectRatio,
  isLCP,
}: ResponsiveImageConfig): JSX.Element {
  const defaultWidth = widths[Math.floor(widths.length / 2)];
  const height = Math.round(defaultWidth / aspectRatio);

  return (
    <picture>
      <source
        type="image/avif"
        srcSet={generateSrcSet(basePath, widths, 'avif')}
        sizes={sizes}
      />
      <source
        type="image/webp"
        srcSet={generateSrcSet(basePath, widths, 'webp')}
        sizes={sizes}
      />
      <img
        src={`${basePath}-${defaultWidth}w.jpg`}
        srcSet={generateSrcSet(basePath, widths, 'jpg')}
        sizes={sizes}
        alt={alt}
        width={defaultWidth}
        height={height}
        loading={isLCP ? 'eager' : 'lazy'}
        decoding="async"
        fetchpriority={isLCP ? 'high' : 'auto'}
      />
    </picture>
  );
}

The build-time image generation pipeline:

// scripts/generate-image-variants.ts
import sharp from "sharp";
import * as fs from "fs";
import * as path from "path";

interface ImageJob {
  input: string;
  outputDir: string;
  widths: number[];
  formats: Array<"avif" | "webp" | "jpg">;
  quality: Record<string, number>;
}

async function generateVariants(job: ImageJob): Promise<void> {
  const image = sharp(job.input);
  const metadata = await image.metadata();

  if (!metadata.width) {
    throw new Error(`Cannot read width of ${job.input}`);
  }

  for (const width of job.widths) {
    if (width > metadata.width) continue; // Skip upscaling

    for (const format of job.formats) {
      const outputName = `${path.basename(job.input, path.extname(job.input))}-${width}w.${format}`;
      const outputPath = path.join(job.outputDir, outputName);

      let pipeline = image.clone().resize(width);

      switch (format) {
        case "avif":
          pipeline = pipeline.avif({ quality: job.quality.avif ?? 50 });
          break;
        case "webp":
          pipeline = pipeline.webp({ quality: job.quality.webp ?? 75 });
          break;
        case "jpg":
          pipeline = pipeline.jpeg({
            quality: job.quality.jpg ?? 80,
            progressive: true,
          });
          break;
      }

      await pipeline.toFile(outputPath);

      const stat = fs.statSync(outputPath);
      console.log(`  ${outputName}: ${(stat.size / 1024).toFixed(1)} kB`);
    }
  }
}

// Process all product images
const imageDir = "src/images/products";
const outputDir = "public/images/products";

const files = fs
  .readdirSync(imageDir)
  .filter((f) => /\.(jpg|jpeg|png)$/i.test(f));

for (const file of files) {
  console.log(`Processing ${file}...`);
  await generateVariants({
    input: path.join(imageDir, file),
    outputDir,
    widths: [400, 800, 1200],
    formats: ["avif", "webp", "jpg"],
    quality: { avif: 50, webp: 75, jpg: 80 },
  });
}

The Proof

After implementing responsive images across the e-commerce platform:

MetricBeforeAfterDelta
Image payload (mobile)8.6 MB1.3 MB-85%
LCP, mobile (p75)4.1s2.6s-1,500ms
LCP, desktop (p75)2.8s1.9s-900ms
Page weight (mobile)9.2 MB2.0 MB-78%

The mobile LCP improvement of 1,500ms is the largest single optimization gain in this book. It comes entirely from serving appropriately sized images. No code architecture changes. No framework migration. Just serving the right pixels.

The Trade-off

The build pipeline now generates 3 formats * 3 widths = 9 variants per source image. For the e-commerce platform with 2,400 product images, that is 21,600 image files. Build time for image generation: ~8 minutes on a GitHub Actions runner with 4 vCPUs.

The mitigation: only regenerate images that changed since the last build. The pipeline hashes each source image and skips generation for unchanged files. Incremental builds take 15-30 seconds for a typical PR that changes 2-5 images.

Storage cost: the additional image variants increase the deployment artifact by ~3x compared to a single-format approach. On CDN storage pricing, this is negligible (tens of megabytes at pennies per GB). The bandwidth saving from serving smaller images to mobile users outweighs the storage cost within the first day of traffic.

The sizes attribute requires the developer to know the CSS layout before writing the HTML. If the layout changes (the product grid switches from 4 columns to 3 columns on desktop), the sizes attribute must be updated. A mismatch between sizes and actual CSS layout causes the browser to select the wrong image variant: too large wastes bandwidth, too small shows blurry images. The CI Lighthouse audit includes an “image sizing” check that flags images displayed at a size significantly different from their intrinsic size.