Serving a single 2400px image to every device wastes 80% of bandwidth for mobile users and forces desktop users to download unnecessarily if they only display it at 600px. Responsive images solve this by letting the browser choose the right size and format for its viewport and screen density.

The spec has three tools: srcset (offer multiple sizes), sizes (tell the browser the display size), and <picture> (control format and art direction). Getting them right eliminates the single largest performance bottleneck on most web pages.

srcset with Width Descriptors

Width descriptors (w) tell the browser the intrinsic width of each image variant. The browser combines this with the sizes attribute and the device pixel ratio (DPR) to pick the smallest variant that covers the display area.

<img
  src="photo-800.jpg"
  srcset="photo-400.jpg 400w,
         photo-800.jpg 800w,
         photo-1200.jpg 1200w,
         photo-1600.jpg 1600w"
  sizes="(max-width: 768px) 100vw, 50vw"
  alt="Product photo"
  width="1600"
  height="1200"
>

How the browser decides: On a 375px-wide phone with 2x DPR, sizes says the image displays at 100vw = 375px. At 2x DPR, the browser needs 750 physical pixels. It picks photo-800.jpg (the smallest variant >= 750w). On a 1440px desktop at 1x DPR with the image at 50vw = 720px, it picks photo-800.jpg again. On a 1440px desktop at 2x DPR (Retina), it needs 1440px and picks photo-1600.jpg.

Pixel Density Descriptors (1x, 2x, 3x)

For fixed-size images (avatars, icons, logos), pixel density descriptors are simpler than width descriptors:

<img
  src="avatar-96.jpg"
  srcset="avatar-48.jpg 1x,
         avatar-96.jpg 2x,
         avatar-144.jpg 3x"
  alt="User avatar"
  width="48"
  height="48"
>

Use density descriptors when the image always displays at the same CSS pixel size regardless of viewport. Use width descriptors when the image display size changes with the viewport (responsive layouts).

Don't mix them. An srcset must use either all width descriptors or all density descriptors. Mixing 400w and 2x in the same srcset is invalid.

The sizes Attribute: Why It's Critical

The sizes attribute tells the browser how wide the image will display before the CSS is parsed. Without it, the browser assumes the image is 100vw (full viewport width) and downloads the largest srcset variant every time.

Syntax: A comma-separated list of media conditions and lengths, evaluated left to right:

sizes="(max-width: 480px) 100vw,
       (max-width: 768px) 50vw,
       (max-width: 1200px) 33vw,
       400px"

This reads as: "On viewports up to 480px, the image is full-width. Up to 768px, it's half-width. Up to 1200px, it's one-third. Above 1200px, it's 400px."

Common mistake: matching CSS breakpoints. Don't blindly copy your CSS media query breakpoints into sizes. Measure the actual rendered image width at each breakpoint. If your CSS breakpoints are 576px, 768px, 992px but the image only changes width at 768px (going from full-width to half), your sizes should reflect the image width transitions, not the CSS transitions.

How to Calculate sizes Values

The most reliable method: open DevTools, resize the viewport to each breakpoint, and measure the image's rendered width. Then express it relative to viewport width:

/* If the image is in a max-width: 1200px container with 20px padding: */
sizes="(max-width: 1240px) calc(100vw - 40px), 1200px"

/* If the image is in a 3-column grid on desktop, full-width on mobile: */
sizes="(max-width: 768px) 100vw, 33.3vw"

/* If the image is always 300px wide (card thumbnail): */
sizes="300px"

When in doubt, use calc(). If your layout has padding or margins, calc(100vw - 40px) is more accurate than rounding to the nearest percentage.

The Element: Format Selection and Art Direction

<picture> wraps <source> elements that let you control which image file is loaded based on format support, viewport size, or media conditions.

Format Negotiation

The most common use: serve modern formats with a fallback.

<picture>
  <source srcset="photo-400.avif 400w, photo-800.avif 800w, photo-1200.avif 1200w"
    sizes="(max-width: 768px) 100vw, 50vw" type="image/avif">
  <source srcset="photo-400.webp 400w, photo-800.webp 800w, photo-1200.webp 1200w"
    sizes="(max-width: 768px) 100vw, 50vw" type="image/webp">
  <img src="photo-800.jpg"
    srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1200.jpg 1200w"
    sizes="(max-width: 768px) 100vw, 50vw"
    alt="Description" width="1200" height="800" loading="lazy">
</picture>

The browser evaluates <source> elements top to bottom. It skips any with a type it can't decode. Once it finds a compatible format, it uses that source's srcset and sizes to pick the right resolution. Order matters: always list AVIF before WebP before JPG.

Art Direction

Art direction means serving a different crop or composition for different viewports. A wide landscape hero on desktop might need a tighter portrait crop on mobile to keep the subject visible.

<picture>
  <!-- Mobile: portrait crop -->
  <source media="(max-width: 768px)" srcset="hero-mobile.webp" type="image/webp">
  <source media="(max-width: 768px)" srcset="hero-mobile.jpg">

  <!-- Desktop: wide landscape -->
  <source srcset="hero-desktop.webp" type="image/webp">
  <img src="hero-desktop.jpg" alt="Hero" width="1200" height="400">
</picture>

Art direction vs responsive sizing: If the image is the same crop at all sizes (just resized), use srcset + sizes on a plain <img>. If the image changes crop/composition at different viewports, use <picture> with media attributes. Don't use <picture> when srcset alone would suffice — it's more markup for no benefit.

Generating Responsive Image Sets

Manually creating 4 sizes x 3 formats = 12 files per image doesn't scale. Automate it.

import sharp from 'sharp';
import { mkdir } from 'fs/promises';
import path from 'path';

const WIDTHS = [400, 800, 1200, 1600];
const FORMATS = {
  avif: { quality: 65, effort: 4 },
  webp: { quality: 80, effort: 4 },
  jpg:  { quality: 80, progressive: true, mozjpeg: true }
};

async function generateResponsiveSet(inputPath, outputDir) {
  const name = path.basename(inputPath, path.extname(inputPath));
  await mkdir(outputDir, { recursive: true });

  const meta = await sharp(inputPath).metadata();

  const tasks = [];
  for (const width of WIDTHS) {
    if (width > meta.width) continue;
    for (const [format, options] of Object.entries(FORMATS)) {
      tasks.push(
        sharp(inputPath)
          .resize(width)
          .toFormat(format, options)
          .toFile(path.join(outputDir, `${name}-${width}.${format}`))
      );
    }
  }

  await Promise.all(tasks); // Parallel encoding
  return WIDTHS.filter(w => w <= meta.width);
}

Build integration: Run this in your build script (Vite plugin, Astro image integration, Next.js Image, Webpack loader). Most modern frameworks have built-in or plugin-based responsive image generation. If yours doesn't, the script above is drop-in.

CDN-Based Dynamic Resizing

Instead of generating variants at build time, you can let the CDN resize on the fly. Upload one high-resolution source and request specific dimensions via URL parameters.

Cloudflare Image Resizing:

<img
  srcset="/cdn-cgi/image/width=400,format=auto/photo.jpg 400w,
         /cdn-cgi/image/width=800,format=auto/photo.jpg 800w,
         /cdn-cgi/image/width=1200,format=auto/photo.jpg 1200w"
  sizes="(max-width: 768px) 100vw, 50vw"
  src="/cdn-cgi/image/width=800,format=auto/photo.jpg"
  alt="Description"
  width="1200" height="800"
>

imgix:

srcset="https://example.imgix.net/photo.jpg?w=400&auto=format 400w,
        https://example.imgix.net/photo.jpg?w=800&auto=format 800w,
        https://example.imgix.net/photo.jpg?w=1200&auto=format 1200w"

The CDN handles format negotiation via the Accept header (when using format=auto), so you don't even need the <picture> element — a single <img> with srcset gets optimal format and size.

Common Responsive Image Patterns

Full-width hero image:

<img srcset="hero-600.webp 600w, hero-1200.webp 1200w, hero-2400.webp 2400w"
  sizes="100vw" src="hero-1200.webp" alt="Hero" width="2400" height="800"
  fetchpriority="high" decoding="async">

Card grid (3 columns desktop, 1 column mobile):

<img srcset="card-300.webp 300w, card-600.webp 600w, card-900.webp 900w"
  sizes="(max-width: 768px) 100vw, 33.3vw" src="card-600.webp" alt="Card"
  width="900" height="600" loading="lazy">

Sidebar image (fixed 300px on desktop, full-width on mobile):

<img srcset="sidebar-300.webp 300w, sidebar-600.webp 600w"
  sizes="(max-width: 768px) 100vw, 300px" src="sidebar-300.webp" alt="Sidebar"
  width="600" height="400" loading="lazy">

Avatar (fixed 48px, DPR-aware):

<img srcset="avatar-48.webp 1x, avatar-96.webp 2x, avatar-144.webp 3x"
  src="avatar-96.webp" alt="User" width="48" height="48">

Responsive images require more markup than a plain <img src>, but the bandwidth savings are massive — typically 50-80% reduction for mobile users compared to serving desktop-sized images to everyone. The srcset + sizes combination handles 90% of cases. Add <picture> when you need format selection or art direction.

If the markup feels verbose, abstract it. Most frameworks have responsive image components that generate the correct attributes from a single high-res source. The underlying HTML is the same — the component just generates it for you.