ddtcorex

11 Oct 2026 · 6 min read

A modern image pipeline for Magento 2

Serve AVIF and WebP through responsive srcsets and CDN transforms to halve product image weight without touching templates

Part 8 of the Storefront Conversion Engineering series — images are the heaviest asset left.

Why this matters

On a typical Magento storefront, product and category images account for more bytes than JavaScript, CSS, and fonts combined. A category page with twenty product tiles can easily ship six megabytes of JPEGs where one megabyte of modern formats would look identical. That weight lands directly on Largest Contentful Paint: the hero product image is usually the LCP element, and every hundred kilobytes of avoidable image payload delays it on mid-range phones and congested networks.

The fix is not manual recompression. It is a pipeline: source images stored once at high quality, derivatives generated deterministically per breakpoint and format, modern formats negotiated per browser, and a CDN doing the resize and transcode work at the edge. Built once, the pipeline keeps every future product upload fast without editors thinking about formats at all.

How it works

Four stages, in order: canonical source, responsive markup, format negotiation, and edge transforms.

Keep one canonical source per image role

Magento already distinguishes image roles: base, small, swatch, thumbnail, category, and CMS content. Keep exactly one high-quality source per role, ideally at the largest rendered width plus a margin for high-DPI screens. A base image stored at 1600px wide covers a 1200px product gallery slot at 2x density with room to crop. Storing five pre-resized variants in the media gallery invites drift; storing one source plus deterministic derivative rules keeps behavior reproducible.

Validate uploads at ingest: reject CMYK JPEGs, strip EXIF orientation by normalizing to orientation 1, cap absurd dimensions, and enforce a minimum quality floor. A surprising share of slow storefronts trace back to a single supplier feed uploading 6000px, 12MB JPEGs straight into the catalog.

Emit responsive srcsets, not single img tags

A single img with a fixed 800px source over-serves phones and under-serves desktops. Replace it with srcset plus sizes so the browser picks the smallest sufficient candidate:

<img
  src="/media/catalog/product/cache/800x/img.jpg"
  srcset="/media/catalog/product/cache/480x/img.jpg 480w,
          /media/catalog/product/cache/800x/img.jpg 800w,
          /media/catalog/product/cache/1200x/img.jpg 1200w"
  sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 400px"
  width="800" height="1000"
  loading="lazy" decoding="async" alt="Leather weekender bag in tan" />

Three details matter. First, sizes must describe the actual CSS layout, not a guess: measure the rendered tile width at each breakpoint and encode it. A wrong sizes makes the browser download a candidate twice as large as needed. Second, always include width and height so the browser reserves layout space and avoids Cumulative Layout Shift while the image loads. Third, keep the hero or LCP image eager and fetch-prioritized while lazy-loading everything below the fold; lazy-loading the LCP image is one of the most common self-inflicted LCP regressions.

On Luma-based themes this means overriding gallery and listing templates to emit srcset; on Hyva, the same logic lives in Alpine-driven gallery components with Tailwind sizing classes. Either way, centralize the breakpoint list (480, 800, 1200 is a solid default) in one view-model or helper so templates never hardcode widths.

Negotiate AVIF first, WebP as fallback

AVIF compresses photographic product shots roughly 30 to 50 percent better than JPEG at equivalent visual quality, and WebP roughly 25 to 35 percent better, with broader browser support. The practical pattern is AVIF primary, WebP fallback, JPEG last resort, selected by the browser through picture or content negotiation:

<picture>
  <source type="image/avif" srcset="bag-480.avif 480w, bag-800.avif 800w, bag-1200.avif 1200w" sizes="(max-width: 640px) 50vw, 400px" />
  <source type="image/webp" srcset="bag-480.webp 480w, bag-800.webp 800w, bag-1200.webp 1200w" sizes="(max-width: 640px) 50vw, 400px" />
  <img src="bag-800.jpg" width="800" height="1000" loading="lazy" decoding="async" alt="Leather weekender bag in tan" />
</picture>

Alternatively, serve one URL and let the CDN vary on Accept: browsers advertising image/avif get AVIF bytes, others get WebP or JPEG, with Vary: Accept on the response so shared caches key correctly. The picture approach is more explicit and easier to debug; Accept-based negotiation keeps markup simpler. Pick one and apply it consistently.

Quality settings deserve explicit decisions, not defaults. Start around AVIF quality 50 to 60 and WebP quality 70 to 75 for product photography, then spot-check textured materials (leather grain, knitwear, brushed metal) where aggressive AVIF smoothing erases the detail shoppers zoom to inspect. Text overlays and line-art diagrams should stay PNG or SVG; lossy photographic formats mangle sharp edges.

Push resize and transcode to the CDN edge

Generating every breakpoint-format combination at deploy time multiplies storage and slows cache warming. The better division of labor: Magento (or the build) defines the transform recipes, and the CDN executes them at the edge on first request, then caches the result. Fastly Image Optimizer, Cloudflare Polish with image resizing, and Cloudinary-style fetch URLs all follow this shape: the origin serves the canonical source, the edge URL encodes width plus format, and the transformed bytes cache with long TTLs plus content-hash or versioned paths.

origin:  /media/catalog/product/b/a/bag-tan.jpg
edge:    /cdn-cgi/image/width=800,format=avif/media/catalog/product/b/a/bag-tan.jpg

Set immutable cache headers on transformed URLs (Cache-Control: public, max-age=31536000, immutable) since the URL itself encodes the transform; purging then means versioning the path, never guessing TTLs. Keep the origin-to-edge fetch authenticated or signed where supplier imagery is not yet public, and monitor edge hit ratio for image paths separately from HTML: a low image hit ratio means the breakpoint list is fragmenting the cache with too many widths.

Pitfalls

  • Converting without responsive markup. Shipping one 1600px AVIF to every phone saves bytes per pixel but wastes them per layout. Format and breakpoints multiply; either alone halves the gain.
  • Forgetting Vary: Accept on negotiated URLs. Without it, a shared cache serves AVIF bytes to a browser that only understands JPEG. Broken images from a correct pipeline are almost always a missing Vary header.
  • Lazy-loading the LCP image. Category heroes and first product gallery images must be eager with fetchpriority="high". Audit with a throttled mobile profile, not a desktop office connection.
  • Upscaling small supplier images. A 600px source rendered at 800px looks soft in every format. Reject or flag undersized uploads at ingest instead of letting the pipeline upscale them silently.
  • Ignoring zoom and swatch paths. Shoppers who pinch-zoom load the full-resolution derivative; if that path bypasses the CDN transform, one interaction blows the whole page budget. Route gallery zoom sources through the same pipeline.

Closing the series

This Storefront Conversion Engineering arc moved from discovery (search, navigation, and merchandising) through persuasion (product pages, social proof, and urgency that respects trust), to measurement that survives consent, and finally to the pixels themselves. The thread throughout: conversion is engineered in the storefront, not wished for in the dashboard. Fast, honest, well-measured pages sell. Everything else is commentary.

This was the final post in the Storefront Conversion Engineering series.

magento2performanceimages