Responsive Images and Art Direction
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:
sizesevaluates to50vw= 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:
sizesevaluates to25vw= 360px- Display pixels needed: 360 * 1 = 360px
- Browser selects
product-400w.avif - Download: 28KB
On a 1440px Retina desktop with 2x DPR:
sizesevaluates to25vw= 360px- Display pixels needed: 360 * 2 = 720px
- Browser selects
product-800w.avif - Download: 62KB
The Baseline
Product listing page image payload by device class:
| Device | Images (no srcset) | Images (with srcset) | Saving |
|---|---|---|---|
| Mobile (375px, 2x) | 8.6 MB | 1.3 MB | -85% |
| Tablet (768px, 2x) | 8.6 MB | 2.8 MB | -67% |
| Desktop (1440px, 1x) | 8.6 MB | 1.3 MB | -85% |
| Desktop (1440px, 2x) | 8.6 MB | 3.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:
| Metric | Before | After | Delta |
|---|---|---|---|
| Image payload (mobile) | 8.6 MB | 1.3 MB | -85% |
| LCP, mobile (p75) | 4.1s | 2.6s | -1,500ms |
| LCP, desktop (p75) | 2.8s | 1.9s | -900ms |
| Page weight (mobile) | 9.2 MB | 2.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.