1. A 4.1MB Product Page and a Build That Took Nine Minutes
A footwear retailer sent me a Lighthouse report with a note that said "images". The product page weighed 4.1MB, of which 3.7MB was images, and the largest contentful paint on a throttled mobile profile was 6.2 seconds. That much was obvious from the report. What was not obvious, and what took me two days to work out, was that the team had already solved this problem twice.
They had a build step that resized every product image into five widths and converted each to WebP. It worked. It also took nine minutes to run, produced 40,000 files, and had been quietly disabled four months earlier because someone needed a hotfix deployed in a hurry and the build kept timing out on CI. Nobody re-enabled it. The templates still emitted a srcset pointing at the resized files; the CDN was configured to fall back to the original upload when a variant 404'd; and the fallback was serving 2400px JPEGs straight out of the merchandising team's Dropbox export to phones on 4G.
The report said "images". The actual problem was that image optimisation had been built as a batch job over a fixed set of inputs, in a business where the set of inputs changes every day and nobody who adds an image knows the build exists.
This article is about the pipeline rather than the markup. Choosing the right format and writing correct srcset is covered elsewhere and I am going to assume it. What I want to talk about here is the machinery underneath: where transformation happens, how you decide between AVIF and WebP with actual byte counts rather than vibes, how quality settings should differ by what the image is, when art direction earns its complexity, and what all of this costs when the invoice arrives.
2. Why Build-Time Resizing Stops Working
Generating variants at build time is the first thing everyone tries, and for a marketing site with sixty images it is completely correct. Run sharp over a directory, emit widths, commit the output or push it to a bucket, done. No runtime cost, no vendor, no surprise invoice.
It breaks on commerce for four reasons, and they compound.
The input set is unbounded and changes without a deploy. A merchandiser uploads forty new SKUs on a Tuesday afternoon with eight images each. Under a build-time model those images do not exist in optimised form until someone ships a build. If your deploy cadence is weekly, you have a week of unoptimised images in production, and the fallback path — serve the original — is exactly the path that produces the 4.1MB page.
Build time scales with catalogue size, not with change. Most naive pipelines re-encode everything on every run because working out what changed requires content hashing that people skip. A 12,000-SKU catalogue at eight images each with five widths and two formats is 960,000 encodes. AVIF at reasonable effort settings runs somewhere around 300ms to 2s per megapixel depending on the encoder and the speed setting. Do that arithmetic and you get a build measured in days, not minutes.
Variant decisions get frozen. You picked 320, 640, 960, 1280, 1920 eighteen months ago. Now you want to add an AVIF tier, or drop 320 because your analytics say nothing renders below 375 CSS pixels, or add a 2560 for a zoom viewer. Under a build model that is a full regeneration and a cache purge. Under a transform-on-request model it is a template change.
The person adding the image is not the person who owns the pipeline. This is the one that actually kills it. Every build-time pipeline I have seen degrade did so because someone bypassed it — pasted a CDN URL into a CMS field, uploaded a hero directly to the bucket, embedded a full-resolution PNG in a rich-text description. The pipeline has no way to catch that, because it only knows about files in a directory it was pointed at.
My position: build-time generation for anything in your repository — logos, icons, editorial art, the handful of hero images a designer produces — and transform-on-request for anything a non-engineer can upload. Trying to run one model for both is where the failure I opened with came from.
3. What an Image CDN Actually Does
The term covers a few genuinely different products, and conflating them causes bad procurement decisions.
At the simplest end there is a transforming proxy. It sits in front of your origin, receives a request with transformation parameters in the URL or query string, fetches the original from origin if it does not already have it, applies the transformation, stores the result, and serves it. Cloudflare Image Resizing, Fastly Image Optimizer, Akamai Image and Video Manager, and imgproxy self-hosted all work this way. Your originals stay where they are; the CDN is a cache with a resize step.
Then there is storage plus transformation: Cloudinary, imgix with its own storage, Cloudflare Images. You upload the master to them, they own the canonical copy, and you address derived versions by ID. This is more convenient and considerably more locked-in, because your image URLs now contain a vendor's hostname and your originals live in a system whose export path you have probably not tested.
And there is the DIY version: object storage for masters, an edge function or a small service that transforms on cache miss, and a CDN in front. More work up front, radically cheaper at scale, and the option most large catalogues eventually land on.
The functional core is identical in all three. A request arrives describing a desired output. Something checks whether that exact output is already cached. If not, it fetches the master, decodes, resizes, re-encodes in the requested or negotiated format, and writes the result to cache. The interesting engineering is almost entirely in that cache: what the key is, how many distinct keys you generate, and how long they live.
Content negotiation, and why I mostly avoid it
Most image CDNs will serve AVIF or WebP automatically based on the request's Accept header, without you writing a <picture> element. This is genuinely convenient and I use it for CMS-authored content where the markup is out of my control.
It has a cost that people underestimate: every cached object now varies by Accept, and Accept headers are surprisingly diverse across browsers and versions. A single logical image can become four or five cache entries. Worse, if any layer between you and the user does not honour Vary: Accept correctly — an aggressive corporate proxy, a misconfigured shared cache, a service worker you wrote yourself — a Safari 14 user gets an AVIF they cannot decode and sees a broken image.
For the handful of images that matter most — the LCP image on a product page, the hero on the homepage — I write explicit <picture> sources. The browser tells me what it can decode by picking a source, the cache key is the URL, and there is no Vary involved. For the long tail, negotiation is fine.
4. The Transformation URL as an API
Whatever product you pick, you end up with a URL grammar. Treat it as an API surface you own, not as vendor syntax you scatter through templates.
<!-- Do not do this: vendor syntax inline, 400 times, across 60 templates -->
<img src="https://res.cloudinary.com/acme/image/upload/w_800,q_auto,f_auto/v1/products/lamp.jpg" />
The moment that string appears in a template you have made a migration off that vendor into a find-and-replace across your entire codebase, and you have made it impossible to change your quality policy centrally. Wrap it once:
// image-url.js — the only place vendor syntax exists.
// Quality is a policy decision keyed on what the image IS, not a magic number
// sprinkled through templates. See the image-class table further down.
const QUALITY = {
hero: 72, // large, smooth gradients, viewed once, LCP-critical
product: 78, // customers zoom into these; texture matters
thumbnail: 68, // small on screen, many per page
swatch: 85, // tiny, flat colour, banding is very visible
editorial: 74,
ugc: 65, // already recompressed by a phone; do not preserve its noise
};
const BASE = process.env.IMAGE_CDN_BASE; // e.g. https://img.acme.com
export function imageUrl(path, { width, imageClass = 'product', format = 'auto', dpr = 1 } = {}) {
const q = QUALITY[imageClass] ?? 75;
const params = new URLSearchParams({
w: String(Math.round(width * dpr)),
q: String(q),
fm: format,
fit: 'cover',
});
return `${BASE}/${path}?${params}`;
}
Three things this buys you. Changing vendors is one file. Changing quality policy is one file and a cache purge. And you can log or lint calls — I have caught a template asking for a 2400px variant of a 64px thumbnail more than once, and the wrapper is where you notice.
One warning about parameter order and formatting. Cache keys are usually the literal URL string, so ?w=800&q=78 and ?q=78&w=800 are two cache entries for one image. Always build the query string through a single function that emits parameters in a fixed order. I have seen a site running at a 40% cache hit rate because two templates built the same URL with the arguments in different orders.
5. AVIF Versus WebP, With Actual Bytes
Everyone knows AVIF is smaller. Almost nobody has measured how much smaller on their images, and the answer varies enormously by content type — which means the correct decision varies too.
Here is a set I ran for the homeware client, taking six representative masters from their catalogue, encoding each at a visually matched quality target rather than a matched quality number. That distinction matters and I will come back to it. All images resized to 1200px wide first. Sizes in KB.
| Image | JPEG q80 | WebP | AVIF | AVIF vs JPEG | AVIF vs WebP |
|---|---|---|---|---|---|
| Studio product, white background | 148 | 96 | 61 | −59% | −36% |
| Ceramic vase, soft gradient backdrop | 212 | 141 | 78 | −63% | −45% |
| Woven rug, heavy fine texture | 486 | 402 | 371 | −24% | −8% |
| Lifestyle room shot, mixed detail | 318 | 224 | 159 | −50% | −29% |
| Flat-colour brand banner with text | 94 | 38 | 27 | −71% | −29% |
| Model shot, skin tones, shallow DoF | 256 | 181 | 112 | −56% | −38% |
Read the rug row. AVIF saved 8% over WebP on the single heaviest image in the set, and that image was heavy precisely because it was full of high-frequency detail that every lossy codec struggles with. The banner row saved 71% over JPEG, and that image was already small.
The uncomfortable conclusion: AVIF's advantage is largest exactly where the image was already cheap, and smallest exactly where the bytes actually hurt. If you average the percentages you get a headline number around 50% that flatters the format. If you sum the actual bytes across the six — 1514KB JPEG, 1082KB WebP, 808KB AVIF — you get 47% off JPEG and 25% off WebP, and the rug is a fifth of the total on its own.
That does not mean skip AVIF. 25% off your image budget is a real win. It means: measure on your own catalogue, weight by traffic, and do not let a benchmark on a gradient-heavy stock photo set your expectations.
The quality-number trap
WebP quality 80 and AVIF quality 80 are not comparable. They are not on the same scale, they do not mean the same thing to their respective encoders, and a straight port of your existing WebP settings to AVIF will either waste bytes or produce visible artefacts.
In my experience, AVIF at quality 50–60 (on the 0–100 scale libavif exposes, which maps internally to a quantiser) looks roughly like WebP at 75–80 for photographic content. If you migrate by keeping the number the same you will ship AVIFs that are larger than they need to be and conclude the format is disappointing. This is, I think, the single most common reason teams report "AVIF barely helped".
# Do not trust equal quality numbers across formats. Encode a ladder and look.
for q in 30 40 50 60 70 80; do
avifenc --min 0 --max 63 -a end-usage=q -a cq-level=$((63 - q * 63 / 100)) \
--speed 4 master.png "out-avif-q${q}.avif"
done
# Then compare against your WebP baseline perceptually, not by file size alone
for f in out-avif-q*.avif; do
# ssimulacra2 correlates far better with human judgement than PSNR or plain SSIM
echo -n "$f: "; ssimulacra2 master.png "$f"
done
6. Where AVIF Actually Loses
Four cases where I do not use it, and they come up more often than the enthusiasm suggests.
Encode cost on cache miss. AVIF encoding is expensive. At speed 4, which is roughly where quality stops improving meaningfully, a 2000×2000 image takes on the order of a second or two of CPU on a normal core. WebP does the same job in tens of milliseconds. If you transform on request and your cache hit rate is poor — a long-tail catalogue where most SKUs are viewed rarely — that encode cost lands on a real user's request. I have watched a first-view product image take 2.8 seconds to arrive because it was being AVIF-encoded on demand at speed 2.
Very small images. Below roughly 10KB the container overhead and the coarse granularity of AV1's quantisation mean AVIF frequently loses to a well-tuned WebP or even a PNG. Swatches, icons, tiny avatars: measure before assuming. I default to WebP under about 100×100.
Animation. Animated AVIF is technically supported and practically a minefield — decoder performance varies, some players stall, and the tooling is immature. For short loops I use animated WebP, and for anything longer than about three seconds I use a muted looping MP4 or WebM in a <video> element, which decodes in hardware and costs a fraction of the CPU.
Progressive rendering. A progressive JPEG shows a rough version of the whole image early and refines. AVIF has no equivalent that browsers use in practice; it appears when it appears. On a fast connection this is irrelevant. On a slow one, a large progressive JPEG can feel faster than a smaller AVIF even though it finishes later, because something is on screen. This matters for large hero images on poor connections, and it is a real argument for keeping a progressive JPEG in your <picture> fallback rather than a baseline one.
What about JPEG XL
Technically excellent, better than AVIF at high quality, supports lossless recompression of existing JPEGs at around 20% savings with perfect reconstruction. Chrome removed the flag in 2023; Safari 17 shipped support in 2023. As of now you cannot rely on it in a browser context without a fallback chain that costs you more in complexity than JXL saves in bytes.
Where I do use it: archival storage of masters. If you are holding 400GB of original JPEGs in object storage for a transform pipeline, lossless JXL recompression cuts that meaningfully with byte-exact reversibility, and nothing user-facing ever touches the JXL. That is a storage-cost decision, not a delivery one.
7. Encoder Settings That Actually Change the Output
Most of the knobs do nothing useful. These four do.
Effort / speed. Every modern encoder trades CPU for compression. avifenc --speed runs 0 (slowest, best) to 10 (fastest). Going from speed 8 to speed 4 typically buys 8–15% smaller files for roughly 4× the CPU. Going from 4 to 0 buys another 2–3% for 10× more CPU, which is almost never worth it on request but can be worth it for a hero image encoded once at build time. My defaults: speed 4 for build-time, speed 6 for transform-on-request, and speed 8 if you are encoding synchronously in a request path you cannot make asynchronous.
Chroma subsampling. 4:2:0 discards three quarters of the colour information and is nearly free perceptually on photographs. It is visibly destructive on saturated edges — red text on white, brand colours meeting a border, a thin cyan line. For photography use 4:2:0. For anything with graphics, logos, or text in the image use 4:4:4 and accept the larger file, or better, do not put text in a raster image.
# Photographic content: 4:2:0 is the right default
avifenc --yuv 420 --speed 4 --min 20 --max 30 photo.png photo.avif
# Contains a logo, product packaging text, or hard saturated edges
avifenc --yuv 444 --speed 4 --min 18 --max 28 packshot.png packshot.avif
Colour management. A master exported from Adobe RGB or ProPhoto with an embedded ICC profile, resized by a pipeline that strips profiles, comes out desaturated and slightly wrong — and the merchandising team notices this before you do, because they compare against the sample on their desk. Convert to sRGB explicitly during transformation rather than stripping the profile and hoping.
// sharp: convert into sRGB rather than dropping the ICC profile.
// Skipping this is the classic "why do our reds look flat" bug.
await sharp(input)
.rotate() // honour EXIF orientation before any resize
.toColorspace('srgb')
.withMetadata({ icc: 'srgb' }) // tag the output so browsers do not guess
.resize({ width: 1200, withoutEnlargement: true, fit: 'inside' })
.avif({ quality: 55, effort: 4, chromaSubsampling: '4:2:0' })
.toBuffer();
Resize kernel. Lanczos3 is the sensible default and what sharp uses. Where it matters is downscaling by large factors — a 4000px master to a 200px thumbnail in one step produces aliasing on fine detail. Two-stage downscaling, or letting libvips use its shrink-on-load path for JPEG (which decodes at a reduced size and is both faster and cleaner), fixes it. libvips does this automatically when you resize a JPEG; it is one of the reasons sharp is so much faster than ImageMagick.
8. Quality Tuning by Image Class
A single global quality setting is leaving 20–30% on the table. Different images have different perceptual budgets, and the right number depends on what the image is for and how it is viewed.
The framework I use, refined over a few catalogues:
| Class | AVIF q | WebP q | Subsampling | Reasoning |
|---|---|---|---|---|
| Hero / banner | 50 | 72 | 4:2:0 | Large, seen briefly, often overlaid with text. LCP-critical so bytes dominate. |
| Product main | 58 | 78 | 4:4:4 | Customers zoom. Texture and fabric weave carry purchase intent. |
| Product gallery (non-first) | 52 | 74 | 4:2:0 | Viewed after intent is established; slightly softer is acceptable. |
| Grid thumbnail | 48 | 68 | 4:2:0 | Small on screen, dozens per page, sum matters more than any one. |
| Colour swatch | 70 | 88 | 4:4:4 | Flat colour bands hideously. Tiny file regardless, so pay for it. |
| Editorial / blog | 52 | 74 | 4:2:0 | Illustrative, rarely scrutinised. |
| User-generated | 45 | 62 | 4:2:0 | Already JPEG-compressed by a phone; preserving its artefacts is pointless. |
The UGC row is worth dwelling on. A customer review photo arrives as a 3MB JPEG that has been through a phone's encoder, possibly a messaging app's re-encode, and an upload resize. It contains sensor noise and existing compression artefacts. Encoding it at high quality faithfully preserves noise nobody wants and costs real bytes. Encode it low, and consider a mild denoise pass first — noise is expensive to encode and its absence is rarely noticed on a review thumbnail.
The swatch row is the counter-intuitive one. A 60×60 swatch of a single flat colour compresses to almost nothing at any quality, but at aggressive quantisation a subtle gradient in a fabric swatch turns into visible bands, and a customer who orders "sage" and receives something that does not match their screen initiates a return. Spending 400 extra bytes to avoid that is trivially correct.
9. Judging Quality Without Staring at Images
"Does it look OK" does not scale to 12,000 SKUs and does not survive the person who made the judgement leaving. You need a metric, and the obvious metrics are bad.
PSNR measures pixel-level error and correlates poorly with what people notice — it punishes an imperceptible global shift and rewards a codec that smears detail. Plain SSIM is better and still misses chroma artefacts and banding. The metric worth using is SSIMULACRA2, which was designed against human ratings and behaves sensibly across the quality range. Roughly: above 90 is visually lossless, 70–90 is high quality where differences need a flicker test to see, 50–70 is acceptable for web delivery, below 30 is visibly degraded.
Once you have a metric, you can invert the problem. Rather than picking a quality number and accepting whatever size results, pick a quality target and search for the smallest file that meets it.
// Binary search for the lowest quality that still hits a perceptual target.
// Run this at build time for hero images, or offline to calibrate the constants
// you then hard-code per image class. Do NOT run it per request.
import sharp from 'sharp';
import { ssimulacra2 } from './metrics.js';
async function encodeToTarget(input, target = 78, lo = 30, hi = 90) {
const reference = await sharp(input).raw().toBuffer({ resolveWithObject: true });
let best = null;
while (hi - lo > 2) {
const mid = Math.round((lo + hi) / 2);
const candidate = await sharp(input).avif({ quality: mid, effort: 4 }).toBuffer();
const score = await ssimulacra2(reference, candidate);
if (score >= target) {
best = { quality: mid, buffer: candidate, score };
hi = mid; // met the bar; try to go smaller
} else {
lo = mid; // missed the bar; need more quality
}
}
return best;
}
Running this across a sample of a few hundred images per class is how you derive the numbers in the table above for your catalogue. It is a calibration exercise, done once and repeated when your photography style changes, not something that belongs in a request path.
One caveat from experience: the metric will happily tell you a heavily denoised image scores well. If your pipeline includes noise reduction, evaluate against the original master, not against the denoised intermediate, or you are grading your own homework.
10. Art Direction Versus Resolution Switching
These are different problems and conflating them produces both unnecessary complexity and missed opportunities.
Resolution switching is: same image, same crop, same subject, different pixel dimensions because the display area and device pixel ratio differ. This is the overwhelmingly common case, it is what srcset plus sizes exists for, and it needs exactly one source URL pattern.
Art direction is: a genuinely different image at different breakpoints. A wide lifestyle shot on desktop where the product occupies a third of the frame, cropped tight to the product on mobile where a third of a 375px viewport is unreadable. Different composition, different crop, sometimes different photograph entirely.
Art direction requires <picture> with <source media>, because the browser cannot infer intent. It also requires a human to decide the crop, which is the part that makes it expensive.
<picture>
<!-- Mobile: tight square crop, subject centred. A different composition,
not a smaller version of the desktop image. -->
<source media="(max-width: 600px)"
type="image/avif"
srcset="/i/hero.jpg?w=600&h=600&fit=crop&crop=focalpoint&fp-x=0.62&fp-y=0.4&fm=avif 600w,
/i/hero.jpg?w=1200&h=1200&fit=crop&crop=focalpoint&fp-x=0.62&fp-y=0.4&fm=avif 1200w"
sizes="100vw" />
<!-- Desktop: full 16:9 scene -->
<source media="(min-width: 601px)"
type="image/avif"
srcset="/i/hero.jpg?w=1280&h=720&fit=crop&fm=avif 1280w,
/i/hero.jpg?w=1920&h=1080&fit=crop&fm=avif 1920w,
/i/hero.jpg?w=2560&h=1440&fit=crop&fm=avif 2560w"
sizes="100vw" />
<img src="/i/hero.jpg?w=1280&h=720&fit=crop" alt="Linen sofa in a bay window"
width="1280" height="720" fetchpriority="high" decoding="async" />
</picture>
Note fp-x and fp-y. Most transforming CDNs support a focal point, expressed as normalised coordinates, that the crop is centred on. This is how you get art direction without a human cropping every image: the merchandiser sets one focal point per image in the PIM, and every crop ratio derives from it automatically. Some CDNs also offer automatic subject detection, which works well enough on product photography with a clean background and badly on lifestyle shots where it will confidently centre on a cushion.
My honest position on art direction: worth it for the homepage hero, category banners, and any editorial header. Not worth it for product images, where the crop is standardised by the photography brief anyway and the marginal gain does not justify a second set of URLs to keep in sync. I have seen teams build a full art-direction system for a product grid and then discover the merchandising team was using the desktop crop for everything because setting two crops per SKU doubled their upload time.
The DPR question
Do you serve a 3× image to a 3× device? Mostly no, and this is where a lot of budget disappears quietly.
The perceptual return above 2× is small — the pixels are below the angular resolution most people resolve at normal viewing distance — while the byte cost keeps rising roughly with area. A 3× image is 2.25× the pixels of a 2×. I cap effective DPR at 2 for photographic content and let quality drop slightly as DPR rises, because compression artefacts at 3× are themselves below the resolution threshold. Some CDNs expose this directly as a DPR-aware quality parameter; if yours does not, encode the high-DPR variants at a lower quality number in your URL builder.
// Cap DPR and taper quality as density rises. A 3x screen showing a slightly
// softer image is indistinguishable; a 3x screen showing 2.25x the bytes is not.
function densityAdjusted(width, dpr, baseQuality) {
const capped = Math.min(dpr, 2);
const quality = capped >= 2 ? baseQuality - 8 : baseQuality;
return { width: Math.round(width * capped), quality };
}
11. Choosing the Width Ladder
How many widths should you generate? The standard answer of five is arbitrary and usually wrong in both directions.
Too few and you serve a 1920px image into a 1100px slot, wasting roughly 40% of the bytes to no visible benefit. Too many and you fragment your cache: each additional width multiplies your cache entries by the number of formats, and on a long-tail catalogue that means more cold misses, more origin fetches, and more encode CPU.
The right method is to derive the ladder from your actual layout and your actual traffic. Your sizes attribute tells you what CSS widths the image occupies at each breakpoint; your analytics tell you the distribution of viewport widths and DPRs. Multiply those out and you get a histogram of required pixel widths, which will have three or four clear clusters rather than a smooth spread — because device widths cluster.
A pragmatic approach that avoids the analysis: use a ladder with roughly 1.4× steps, which keeps worst-case waste under about 30%, and stop at the largest width your layout can ever request multiplied by 2. For a product image that maxes out at 720 CSS pixels, that is 320, 448, 640, 900, 1280, 1440 — and you can trim to five by dropping one of the middle steps.
Then check what is actually being requested. Every image CDN can report this, and the report is usually surprising.
# Which widths are your users actually pulling? Run against a day of CDN logs.
# If a rung accounts for under 2% of requests, it is fragmenting your cache
# for no benefit and should be removed from the srcset.
awk -F'[?&]' '{for(i=2;i<=NF;i++) if($i ~ /^w=/) print $i}' cdn-access.log \
| sort | uniq -c | sort -rn | head -20
On the homeware site this showed that 320w accounted for 0.3% of requests — the site's minimum layout width meant it was never selected on any real device — while an undeclared gap between 900 and 1440 meant a large cluster of 1366×768 laptops at DPR 1 was pulling the 1440 variant and downscaling it. Removing one rung and adding another cut average image bytes per product page by 11% with no visual change.
12. Cache Keys and the Variant Explosion
This is the failure mode that surprises people when the bill arrives, and it is pure arithmetic.
Take one master image. Six widths. Three formats (AVIF, WebP, JPEG fallback). Two crop ratios for art direction. That is 36 derived objects per master. At eight images per SKU and 12,000 SKUs you are looking at 3.4 million cached objects. If your CDN charges per transformation, or per unique image served, or has a cache size cap after which things start evicting, you have just designed yourself a problem.
Four things that keep it under control.
Normalise the URL before it becomes a cache key. Strip unknown query parameters, sort known ones, round widths to your ladder rungs, clamp quality to a permitted set. A request for w=1237 should be served the w=1280 variant, not encoded fresh. Without clamping, one bad template or one scraper can generate tens of thousands of one-off variants.
// Edge worker: snap to the ladder before anything touches the cache.
// Without this, w=799 and w=800 are two cache entries and two encodes.
const LADDER = [320, 448, 640, 900, 1280, 1440, 1920, 2560];
const FORMATS = new Set(['avif', 'webp', 'jpeg']);
function normalise(url) {
const u = new URL(url);
const requested = parseInt(u.searchParams.get('w') || '0', 10);
const snapped = LADDER.find(w => w >= requested) ?? LADDER[LADDER.length - 1];
const fm = u.searchParams.get('fm');
const q = Math.round(parseInt(u.searchParams.get('q') || '75', 10) / 5) * 5;
// Rebuild with a fixed parameter order and nothing else. Any tracking or
// junk parameter that survives here becomes a separate cache entry.
const clean = new URLSearchParams();
clean.set('w', String(snapped));
clean.set('q', String(Math.min(95, Math.max(40, q))));
clean.set('fm', FORMATS.has(fm) ? fm : 'auto');
u.search = clean.toString();
return u.toString();
}
Version by content, not by time. Put a content hash of the master in the path, set Cache-Control: public, max-age=31536000, immutable, and never purge. When the merchandiser replaces an image, the hash changes and the URL changes. Purging a CDN by wildcard across 3.4 million objects is slow, sometimes rate-limited, and on some providers billed.
Pre-warm the variants that matter. For a new product launch or a homepage takeover, fire requests for the top few variants ahead of the campaign so the first real customer does not pay for the encode. A script that walks your sitemap's new URLs and requests each declared srcset candidate is twenty lines and removes an entire class of "the new collection page is slow for the first ten minutes" complaint.
Cap the tail. If a variant has been requested once in ninety days it is costing you storage and buying you nothing. Most CDNs evict on LRU anyway; the point is to not design a system that depends on a 3.4-million-object cache staying warm.
13. What This Costs
Pricing models differ enough that a like-for-like comparison requires knowing your own numbers first: monthly image requests, unique masters, unique derived variants, and total egress.
The three models you will encounter:
Per transformation. You pay for each unique derived image created, usually with cached serves free or much cheaper. Generous-sounding until you compute your variant count. A catalogue with 96,000 masters and 36 variants each will burn through a "25,000 transformations included" tier during the first crawl.
Per unique master ("images stored" or "billable assets"). You pay per original, and derive as many variants as you like. This is the model that suits large catalogues with heavy variant fan-out, and it is why I usually steer commerce clients toward it. Watch the definition though: some vendors count a re-upload of the same image as a new asset, and a nightly PIM sync that re-pushes unchanged images will quietly multiply your bill.
Per bandwidth. Straightforward, predictable, and the model where optimisation directly reduces the invoice. This is what you get with a plain CDN plus your own transform layer.
A rough shape for a mid-size catalogue — 12,000 SKUs, 96,000 masters, around 4 million image requests a month, 2.5TB egress after optimisation:
| Approach | Rough monthly cost | Engineering effort | Main risk |
|---|---|---|---|
| Managed storage + transform (per-asset tier) | $400–900 | Days | Lock-in; URLs contain vendor host |
| Transforming proxy over your own origin | $250–600 | Days | Origin must survive cache-miss storms |
| Object storage + edge worker + CDN | $80–250 | Weeks | You own the encode failures at 3am |
| Build-time generation + plain CDN | $60–150 | Weeks, plus ongoing | Breaks the moment a non-engineer uploads |
Those ranges are wide because the variables are wide, and I would not quote them to a finance team without running your own numbers. What the table is for is the shape: the managed options cost roughly three to five times the DIY option and save you weeks of work plus an ongoing operational burden. For a business doing under about £5m online, the managed option is correct and arguing otherwise is engineering vanity. Above that, the arithmetic starts to favour building it, and by the time you are at serious volume the savings fund the team that maintains it.
One cost that is invisible on every pricing page: egress from your origin to the image CDN on cache miss. If your masters sit in S3 and your image CDN is elsewhere, every cold fetch of a 6MB master is billed egress. On a long-tail catalogue with a poor hit rate this has been, on one project I worked on, larger than the CDN bill itself. Put your masters somewhere with free or cheap egress to your CDN, or accept the line item knowingly.
14. Building It Yourself
If you land on the DIY option, the shape is stable and worth knowing even if you buy instead, because it tells you what you are paying for.
// Cloudflare Worker in front of R2. Cache-first, transform on miss, and
// crucially: never let two concurrent misses for the same key both encode.
export default {
async fetch(request, env, ctx) {
const normalised = normalise(request.url);
const cacheKey = new Request(normalised, request);
const cache = caches.default;
const hit = await cache.match(cacheKey);
if (hit) return hit;
const u = new URL(normalised);
const master = await env.MASTERS.get(u.pathname.slice(1));
if (!master) return new Response('not found', { status: 404 });
// cf.image runs the resize at the edge; the encode cost is Cloudflare's,
// not ours, and the result is what we then store in our own cache.
const transformed = await fetch(new Request(master.url), {
cf: {
image: {
width: Number(u.searchParams.get('w')),
quality: Number(u.searchParams.get('q')),
format: u.searchParams.get('fm'),
fit: 'scale-down',
metadata: 'none',
},
},
});
const response = new Response(transformed.body, transformed);
response.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
response.headers.set('Vary', 'Accept');
// waitUntil so the cache write does not delay the user's response
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
},
};
The thundering-herd problem in that comment is real and I have been bitten by it. A homepage hero goes live, ten thousand people hit it in the first minute, and ten thousand concurrent requests all miss the cache and all start an AVIF encode. Whatever is doing the encoding falls over. The fixes are a request-coalescing lock keyed on the normalised URL, or a pre-warm step in your deploy, or both. Pre-warming is simpler and I would do that first.
If you are running your own encode workers rather than using an edge platform's transform, sharp on top of libvips is what you want. It is dramatically faster than ImageMagick — a factor of four to five on typical resize workloads — because libvips streams tiles rather than materialising whole images in memory, and because it uses shrink-on-load for JPEG. The memory profile matters when you are running many concurrent transforms in a container with a limit.
// Concurrency control matters more than raw speed. Unbounded parallel encodes
// in a 512MB container is how you get OOM kills that look like random 502s.
import sharp from 'sharp';
import pLimit from 'p-limit';
sharp.cache({ memory: 200 }); // libvips operation cache, in MB
sharp.concurrency(2); // libvips threads PER operation
const limit = pLimit(4); // simultaneous transforms in this process
export const transform = (buf, opts) => limit(() =>
sharp(buf, { limitInputPixels: 100e6 }) // reject decompression bombs
.rotate()
.resize(opts.width, null, { withoutEnlargement: true, fit: 'inside' })
.avif({ quality: opts.quality, effort: 4 })
.toBuffer()
);
limitInputPixels is not paranoia. A crafted PNG that decompresses to a 40,000×40,000 canvas will take your worker out, and if your upload path accepts images from customers you have handed anyone a denial-of-service primitive.
15. The Input Problem Nobody Budgets For
Every pipeline discussion assumes clean masters. Real masters are not clean, and the time you spend on this will exceed the time you spend on encoders.
EXIF orientation. A photo shot on a phone held sideways is stored in sensor orientation with a rotation flag. Strip the metadata before rotating and you have sideways product images. Call .rotate() with no argument first — that applies the EXIF orientation and clears the flag — then do everything else.
CMYK TIFFs from the print team. They will send you the file that went to the catalogue printer. Converting CMYK to sRGB without a proper profile-aware conversion produces colours that are noticeably off, and "noticeably off" on a paint or fabric retailer means returns.
Alpha channels where you do not expect them. A PNG with transparency composited onto white by one code path and onto the page background by another gives you two different-looking product grids. Flatten explicitly, against an explicit colour, and put that colour in a config rather than in three places.
Masters that are too small. Someone uploads a 640px image for a slot that wants 1440. Upscaling produces a soft image that looks worse than the small one letterboxed. withoutEnlargement: true prevents the upscale; you also need a validation step at upload that rejects or flags undersized masters, because otherwise the problem surfaces on the storefront rather than in the tool where it can be fixed.
Absurdly large masters. The other end of the same problem. A 90MB TIFF from a photographer's raw export sitting in your bucket costs storage, costs egress on every cold transform, and decodes slowly. Normalise on ingest: convert everything to a sensible master format at a sensible maximum dimension — I use 3000px on the long edge and either high-quality JPEG or lossless WebP — and archive the true originals somewhere cold if the business wants them kept.
16. A Worked Example, Including the Part That Went Wrong
Back to the footwear retailer. 12,000 SKUs, mostly furniture and soft furnishings, average 6.4 images per product, running Magento with images served from the same origin as the storefront.
Starting state. Product page 4.1MB, of which 3.7MB images. LCP 6.2s on the throttled mobile profile, 4.4s at the 75th percentile in field data. No responsive variants being served in practice because of the disabled build. Masters averaging 2.8MB, some over 20MB.
Week one: ingest normalisation. Every master converted to sRGB, EXIF-rotated, capped at 3000px long edge, re-encoded as quality-92 JPEG. Storage went from 340GB to 47GB. This did nothing for users directly but made everything downstream cheaper and faster, and it caught 217 images that were CMYK and had been rendering with visibly wrong colour for over a year. Nobody had reported it. The merchandising lead was, reasonably, quite annoyed.
Week two: transforming proxy. Put an image CDN in front, moved all templates onto a single URL builder, snapped widths to a six-rung ladder, enabled AVIF with WebP and JPEG fallbacks via explicit <picture> on product and category pages and via Accept negotiation everywhere else.
Week three: quality by class. Applied the class table. Ran the SSIMULACRA2 binary search over 400 sampled images to calibrate, which moved product-main up from my starting guess and thumbnails down.
Results. Product page images went from 3.7MB to 610KB. Total page 4.1MB to 1.05MB. Mobile LCP from 6.2s lab to 2.4s, and field p75 from 4.4s to 2.1s. Category pages, which carried 24 thumbnails, improved proportionally more: 2.9MB to 380KB.
What went wrong. Two things, one embarrassing.
The embarrassing one: I set the ladder's top rung at 1920 and the product page has a zoom viewer that requests the full master on click. Because the template went through the new URL builder and the builder clamped to the ladder, zoom silently started serving 1920px images into a viewer designed for 2500px. It looked soft. Nobody noticed for eleven days, until a customer service ticket mentioned it. The fix was trivial — a zoom image class that bypasses the clamp — but it taught me to enumerate every consumer of an image URL before centralising the builder, not after.
The second: AVIF encoding on cache miss for the long tail was slow enough that first views of rarely-visited products were worse than before for the image request itself. We moved to speed 6 for on-demand encodes and added a nightly pre-warm of the top 2,000 SKUs by traffic, which brought the p95 image response time back under control. If I did it again I would pre-warm from day one rather than discovering the need from a percentile chart.
The honest overall read: the biggest single win was not AVIF and was not the CDN. It was that images were being served responsively at all again, which the client already had a solution for and had lost. Roughly 70% of the improvement came from serving appropriately-sized JPEGs; the format and quality work delivered the remaining 30%. That ratio has held on every project I have done since, and it is worth remembering before you spend three weeks on encoder tuning.
17. Keeping It From Regressing
Everything above degrades. The pipeline that fixed the 4.1MB page will, without a check, allow a 4.1MB page again within a year, because someone will add a section, or a vendor widget will inject images, or a new template will bypass the URL builder.
Two mechanisms that hold. First, a budget in CI with a number in it. Not "keep images small" — a hard failure at a byte count per template.
// Playwright budget check. Runs against a preview deploy on every PR.
// Fails loudly, with the offending URLs, because "images got bigger" is
// useless feedback and "this specific image is 480KB" is actionable.
const BUDGETS = { '/': 700_000, '/collections/sofas': 900_000, '/products/*': 800_000 };
test('image weight budget', async ({ page }) => {
const images = [];
page.on('response', async (r) => {
if ((r.headers()['content-type'] || '').startsWith('image/')) {
images.push({ url: r.url(), bytes: Number(r.headers()['content-length'] || 0) });
}
});
await page.goto(process.env.PREVIEW_URL + '/products/aldwych-3-seat-sofa');
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForLoadState('networkidle');
const total = images.reduce((n, i) => n + i.bytes, 0);
const worst = images.sort((a, b) => b.bytes - a.bytes).slice(0, 5);
expect(total, `worst offenders:\n${worst.map(i => `${i.bytes} ${i.url}`).join('\n')}`)
.toBeLessThan(BUDGETS['/products/*']);
});
Second, a linter that catches raw <img src> pointing anywhere other than through the builder. A grep in CI is enough. The rule is not "images must be optimised", which nobody can enforce; it is "image URLs must come from one function", which a regex can check and which makes optimisation a property of the system rather than of everyone's diligence.
Field monitoring closes the loop. Resource timing gives you the actual transfer size and duration of the LCP image in real conditions, which is the only number that matters, and it will disagree with your lab runs.
// Report the LCP element's real transfer size from the field. Lab numbers
// tell you what could happen; this tells you what did.
new PerformanceObserver((list) => {
const lcp = list.getEntries().at(-1);
if (!lcp?.url) return;
const entry = performance.getEntriesByName(lcp.url)[0];
navigator.sendBeacon('/rum/lcp', JSON.stringify({
url: lcp.url,
renderTime: Math.round(lcp.renderTime || lcp.loadTime),
transferBytes: entry?.transferSize ?? null,
encoded: entry?.encodedBodySize ?? null,
// decodedBodySize >> encodedBodySize means the compression is doing work;
// transferSize 0 with a nonzero decoded size means it came from cache
decoded: entry?.decodedBodySize ?? null,
}));
}).observe({ type: 'largest-contentful-paint', buffered: true });
18. Questions People Ask
"Should we just turn on the CDN's automatic optimisation and move on?" For a small catalogue, honestly yes, and I would not pretend otherwise. Automatic mode picks a format by Accept, applies a generic quality, and gets you most of the way. What it cannot do is know that your swatches need high quality and your review photos do not, and it cannot fix a template requesting a 2400px image for a thumbnail. It optimises the requests you make; it does not fix the requests.
"Our images are already WebP. Is AVIF worth the migration?" Measure your own catalogue with the six-image method above. If your imagery is smooth and studio-lit, expect 25–40% and it is worth it. If it is texture-heavy — textiles, foliage, food close-ups, anything with grain — expect under 15%, and the encode cost may not pay for itself. Nobody can answer this for you from a blog post, including this one.
"How do we handle Safari users?" Safari 16.4 shipped AVIF in March 2023 and has supported WebP since 14. In practice, a <picture> with AVIF then WebP then JPEG covers everything with a browser share worth naming. The rarer problem is a browser that claims AVIF support in Accept and decodes it slowly on old hardware; that is an argument for not sending very large AVIFs to unknown clients, not for avoiding the format.
"Can we skip the JPEG fallback?" Almost. WebP support is effectively universal now, so an AVIF-then-WebP chain covers you. I still keep a JPEG in the <img> element itself because it costs one line and it is what email clients, link preview crawlers, and the occasional in-app browser will fetch. That last group is bigger than you think on a commerce site with paid social traffic.
"Does the CDN's automatic format detection break social sharing previews?" Sometimes, and it is worth checking. Some link-preview crawlers send an Accept: */* and get served AVIF, which several of them cannot render. Serve your Open Graph image from a URL that is pinned to JPEG rather than negotiated. This has cost more than one client a week of flat-looking social posts.
"What about lazy loading?" Different problem, and covered properly in the article on deferring media until it is needed. Briefly: your LCP image must never be lazy-loaded, and everything below the fold should be. The pipeline here determines how big each image is; lazy loading determines when it is fetched. Both matter and neither substitutes for the other.
"Should the LCP image be preloaded?" Yes, with fetchpriority="high" on the <img> itself, which is simpler and does not risk a mismatch between the preload URL and what srcset actually selects. If you do preload, use imagesrcset and imagesizes on the link so the two agree; a preload of the wrong candidate downloads two images and is worse than none. This interacts with your overall critical resource ordering, which is worth reading alongside.
"How often should we re-tune quality?" When your photography changes. A new studio brief, a switch from white-background packshots to lifestyle, or a new supplier whose images arrive pre-sharpened all shift the numbers. Otherwise, once, properly, and then leave it alone. I have watched a team spend a fortnight moving quality from 76 to 74 and back.
"Is it worth generating a blurred placeholder?" A 20-byte inline LQIP prevents the empty-box look and can be encoded into the HTML. It genuinely improves perceived speed on slow connections. It also adds a build step, adds bytes to your HTML, and does nothing for your measured LCP — arguably it hurts, since a placeholder can become the LCP candidate briefly. I use it for hero images and skip it for grids, where a solid colour derived from the image's average is cheaper and looks nearly as good.
19. The Order I'd Work In
Count what you are actually serving before changing anything. Load a product page, filter the network panel to images, and sort by size. If the top entry is over about 300KB, or if the transferred dimensions are more than 1.5× the rendered dimensions, you have a sizing problem and no amount of format work will matter as much as fixing it.
Normalise your masters on ingest. sRGB, EXIF-rotated, capped dimensions, alpha handled explicitly. This is unglamorous, invisible to users, and makes every subsequent step cheaper and more predictable. It is also where you will find the images that have been quietly wrong for a year.
Get every image URL going through one builder function. Not because the abstraction is beautiful but because it is the only way quality policy, format policy, and the width ladder can be changed centrally later. Do this before you tune anything, and enumerate every consumer first — including the zoom viewer, the email templates, and the PDF generator nobody mentioned.
Pick a transform location. Managed CDN if you are under serious scale; edge worker over object storage if you are past it. Do not build a batch pipeline over a catalogue that changes daily.
Then, and only then, the format and quality work. Six representative images, encode a ladder, score them, and derive per-class settings from your own catalogue rather than from a table in an article — including this article's table, which is a starting point and not an answer.
Put a byte budget in CI on the day you finish, not later. The pipeline is not the deliverable; the pipeline plus the thing that stops it silently switching off is the deliverable. The client I opened with had a working pipeline and a 4.1MB page at the same time, and the gap between those two facts is where all the interesting failures live.
Suggested & Related Engineering Guides
Explore related deep-dive technical guides from Kenneth D'Silva:
-
Mastering Core Web Vitals
Understanding CLS, FID, INP, and LCP in deep technical detail for enterprise deployments.
-
Performance Optimization for Magento & Shopify Stores
Implementing full-page Varnish caching, Redis object caching, and asset minification.
-
Why SEO Matters for Enterprise Ecommerce
Technical SEO optimizations, crawl budget management, and schema architecture.