Skip to the content.

Ship a responsive image the browser can choose

A product page ships one 2400px JPEG to every device. On a phone that is most of the page weight, and the layout jumps when it loads.

Build an Image component that emits markup the browser can act on:

Starter files are in practice/workspace/responsive-image-set/<framework>/.

Solution

Approach 1: srcset + sizes on a plain <img>

const WIDTHS = [400, 800, 1200, 2400];

export default function Image({ src, alt, width, height, sizes = "100vw", priority = false }) {
  if (alt === undefined) throw new Error("Image: alt is required (use alt=\"\" for decorative)");
  const srcSet = WIDTHS.map((w) => `${src}?w=${w} ${w}w`).join(", ");
  return (
    <img
      src={`${src}?w=${WIDTHS[1]}`}
      srcSet={srcSet}
      sizes={sizes}
      alt={alt}
      width={width}
      height={height}
      style={{ aspectRatio: `${width} / ${height}`, width: "100%", height: "auto" }}
      loading={priority ? "eager" : "lazy"}
      fetchpriority={priority ? "high" : undefined}
      decoding="async"
    />
  );
}

width and height are the layout contract. With them plus aspect-ratio, the browser reserves the box before a byte arrives, which is what removes the shift.

Approach 2: <picture> with format negotiation

<picture>
  <source type="image/avif" srcSet={set(src, "avif")} sizes={sizes} />
  <source type="image/webp" srcSet={set(src, "webp")} sizes={sizes} />
  <img src={`${src}?w=800`} alt={alt} width={width} height={height} sizes={sizes} />
</picture>

<picture> is for choosing a different resource: a modern format, or a different crop at a different breakpoint. srcset alone is for choosing a different size of the same resource. Reach for <picture> when you have art direction or format fallbacks, and not before — it is three times the markup for no benefit otherwise.

Trade-offs

sizes is the part that is easy to get wrong and expensive when you do. It is a promise about layout that the browser trusts before CSS has been applied, so a stale sizes="100vw" on an image that actually renders at 300px makes the browser download the 2400px file — the exact bug the component was built to prevent. That is the argument for passing sizes explicitly from the call site rather than defaulting it: a wrong default is silent.

loading="lazy" on an above-the-fold image delays the largest paint on the page, which is why priority exists and why it should be used on exactly one image per screen.

Requiring alt by throwing is deliberate. A warning is filtered out of a noisy console; a thrown error is fixed before the commit. alt="" stays available for genuinely decorative images, which makes the intent explicit in the diff.