Image & Media Delivery Optimization
Derive every image and media variant your visitors actually need from a single origin master at the CDN edge, then cache each derivative aggressively enough that the transform runs once and serves millions of times.
Images and video are the majority of bytes on a typical page, and unlike JavaScript they cannot be shrunk by a text codec — Content-Encoding does nothing for a JPEG. The lever that works is serving different bytes to different clients: a 420 px AVIF to a phone, a 1600 px JPEG to a desktop browser that predates modern formats, a 6-second HLS segment instead of a 400 MB progressive MP4. Doing that at the origin means storing dozens of pre-baked files per asset and re-running the whole pipeline whenever a designer changes a crop. Doing it at the edge means the origin keeps one master, the CDN derives on first request, and the derivative lives in cache under its own key. The engineering work is in the URL contract, the cache-key math, and the cost controls that stop the derivative keyspace from growing without bound.
Key implementation points:
- Keep exactly one master per asset at origin and generate every width, crop, quality and format at the edge from a URL that encodes the transform deterministically.
- Negotiate format from the request
Acceptheader (AVIF → WebP → JPEG), but bucket that header before it reaches the cache key orVary: Acceptwill shred your hit ratio — see Cache Key & Vary Configuration. - Constrain the transform surface to a named allow-list of variants so an attacker (or a careless template) cannot mint unbounded derivatives at your CPU expense.
- Treat video as a range/segment caching problem, not an image problem: byte-range requests and HLS/DASH segments need their own cache configuration and their own origin shield.
How an edge transform layer actually works
A transform layer sits between the request and the origin object store and turns a description of the wanted image into bytes. Every implementation, from Cloudflare Image Resizing to a self-hosted imgproxy container, follows the same five steps: parse the transform description out of the request, compute a cache key from it, look that key up, and on a miss fetch the master, decode it, apply the operations, encode to the target format and store the result.
The description reaches the edge in one of two shapes. Path-encoded transforms put the parameters in the URL path — /cdn-cgi/image/width=840,format=avif/photo.jpg on Cloudflare, /rs:fit:840:0/f:avif/plain/s3://media/photo.jpg on imgproxy. Query-encoded transforms put them in the query string — photo.jpg?width=840&format=webp&quality=75 on Fastly Image Optimizer. Path encoding is friendlier to caches that ignore or normalize query strings by default and it survives a strict query-string normalization policy unchanged; query encoding is easier to bolt onto an existing asset URL without rewriting templates. Pick one and be consistent, because two encodings of the same transform are two cache objects holding identical bytes.
Cache placement is the part people get wrong. There are two caches in play: the derivative cache holding the transformed output, and the origin fetch cache holding the master the transform layer pulled down. A well-configured setup caches the master privately at the edge with a long TTL — often via tiered caching and an origin shield — so generating twelve variants of the same photo triggers one origin GET rather than twelve. Without that, enabling image resizing can increase origin egress even though visitor bytes fall, because every cold variant re-downloads the 4 MB master.
The encode is the expensive half. Resizing a 4000 px JPEG down to 840 px costs a few milliseconds; encoding that result as AVIF at a reasonable effort setting costs tens to hundreds of milliseconds and a meaningful chunk of CPU. That asymmetry is why the derivative must be cached with a long, immutable freshness lifetime and why the number of distinct derivatives has to be bounded. A transform layer that accepts arbitrary integer widths will happily encode width=841, width=842, width=843 for anyone who asks.
Responsive markup versus URL-parameter variants
Two mechanisms decide which variant a browser fetches, and they solve different halves of the problem.
srcset and sizes are client-side selection. The server lists candidate resources with their intrinsic widths, the browser combines its layout width, device pixel ratio and (in some engines) network conditions, and it picks one. Nothing about this requires the CDN to vary anything — each candidate is a distinct URL, so each is a distinct, cleanly cacheable object. This is the mechanism to reach for by default: it is the only one that knows the CSS layout width, which the server categorically cannot.
<img
src="/img/hero-840.jpg"
srcset="/img/hero-420.jpg 420w,
/img/hero-840.jpg 840w,
/img/hero-1280.jpg 1280w,
/img/hero-1600.jpg 1600w"
sizes="(max-width: 640px) 100vw, (max-width: 1100px) 60vw, 640px"
width="1600" height="900"
alt="Rack of edge servers in a datacenter aisle">
URL-parameter variants driven by Client Hints are server-side selection. The browser sends Sec-CH-DPR, Sec-CH-Width or Sec-CH-Viewport-Width (only after the server has opted in with an Accept-CH response header), and the edge picks a width. This produces one URL for all clients, which looks tidy, but every hint you consume must enter the cache key or you will serve a phone-sized image to a desktop. Client Hints are also not sent cross-origin unless you delegate them via Permissions-Policy, which is a common reason a CDN-hosted image never sees them.
The pragmatic split most teams land on: use srcset for width selection because the browser knows the layout, and use server-side negotiation only for format, because the browser cannot express “give me AVIF” through srcset without a <picture> element. That division keeps the width dimension out of Vary entirely.
Format negotiation and what it costs the cache key
Format is the one dimension the browser advertises directly. A current Chrome sends Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8 on image subresource requests; Safari has sent image/webp since 16.4 and image/avif since 16; older clients send something far shorter. An edge that inspects that header can hand back the smallest format the client can decode without any markup changes at all.
The bill arrives as Vary: Accept. That response header tells every downstream cache to key on the raw Accept string, and raw image Accept strings vary by browser, by version, and by request destination. A busy site will see dozens of distinct values, each of which becomes a separate stored object for every width of every image. Hit ratio falls, storage climbs, and the AVIF encoder runs far more often than it should.
The fix is normalization: classify Accept into two or three buckets (avif, webp, jpeg) at the edge, key on the bucket, and either omit Vary: Accept in favour of the internal bucket or keep it while ensuring the CDN’s own key uses the bucket rather than the raw header. That whole procedure — bucketing code, provider cache-key config, and the <picture> alternative that avoids Vary entirely — is covered in Negotiating AVIF and WebP with Accept Headers.
Quality and DPR parameters
Quality is not a linear dial and the same number means different things per codec. JPEG quality 80 and AVIF quality 80 are not comparable; AVIF at 50–60 is usually visually equivalent to JPEG 80 at roughly a third of the bytes. Sensible defaults: JPEG 78–82, WebP 75–80, AVIF 50–65. Push quality down as pixel density goes up — a 3× DPR image is downsampled by the display, so compression artifacts are far less visible, and quality 45 AVIF at 3× often beats quality 70 at 2× on both metrics that matter.
Device pixel ratio belongs in the width you request, not in a separate axis. If your layout slot is 320 CSS px and the device is 3×, ask the edge for width=960, not width=320&dpr=3. Collapsing DPR into width halves the number of distinct parameter combinations and makes the derivative keyspace much easier to reason about. It also means a 2× phone and a 1× tablet with a wider slot can share the same cached object.
Video and audio delivery at the edge
Media files break most of the assumptions an image pipeline makes. They are too large to buffer in an edge function, they are fetched with Range requests rather than whole-object GETs, and modern players fetch them as thousands of small segment files instead of one blob.
Byte-range requests. A progressive MP4 is fetched with Range: bytes=0-524287 for the first chunk, then further ranges as the user scrubs. A CDN must either cache the whole object and slice it locally, or cache individual range fragments. Cloudflare and Fastly do the former for objects under their size limits and support range-aware caching above them; CloudFront caches ranges as separate parts once the origin advertises Accept-Ranges: bytes. Three configuration mistakes break this reliably: an origin that ignores Range and returns 200 with the full body (the player then buffers the entire file), an edge function that reads response.body into memory (which strips range semantics and blows the memory limit), and compression applied to the media type — a Content-Encoding on a ranged response makes byte offsets meaningless, which is exactly why edge compression must exclude video/* and audio/*.
Segmented streaming. HLS and DASH split the media into 2–10 second segments plus a manifest. Each segment is an ordinary, immutable, highly cacheable object — this is the single biggest reason to prefer streaming over progressive download at scale. The manifest is the opposite: for live streams it changes every segment duration and must carry a short TTL, typically half the target segment duration, or players will stall on a stale playlist. For video on demand the manifest is immutable and can be cached for a year alongside the segments.
Origin offload. A one-hour VOD at 6 s segments across four bitrate ladders is roughly 2,400 objects. Each one is cheap to cache and each one is an origin miss on first request in every PoP unless you enable a shield. Streaming is where tiered caching pays for itself most obviously: without it, a popular launch multiplies origin reads by the number of PoPs.
Lazy loading, LCP and the ordering trap
loading="lazy" defers a fetch until the image nears the viewport, which is a clear win for the long list of images below the fold. Applied to the hero image it is a straightforward regression: the browser must first lay out the page to know the image is in view, so the Largest Contentful Paint candidate starts downloading hundreds of milliseconds late. The rule is mechanical — never lazy-load anything in the initial viewport, and add fetchpriority="high" plus a <link rel="preload" as="image" imagesrcset="…" imagesizes="…"> to the LCP image so the preload scanner starts it before the CSS finishes.
Two more ordering details bite regularly. Always set width and height attributes (or an aspect-ratio in CSS) so the layout reserves the box and you do not pay a Cumulative Layout Shift penalty when the derivative lands. And remember that a format negotiation round trip that misses cache is slower than serving a slightly larger cached JPEG — a cold AVIF encode on the critical path can cost more milliseconds than the bytes it saves, which is why the important variants get warmed rather than discovered.
Signed and tokenized media URLs
Once the edge can transform anything, the transform endpoint becomes an open compute resource unless you close it. Two controls do the work.
Signing the transform. The URL carries an HMAC over the transform parameters plus the source path, computed with a key only your application knows. imgproxy and thumbor both do this natively; on Cloudflare and CloudFront you implement it in the edge function. An unsigned or mis-signed request is rejected before any decode happens, so an attacker cannot enumerate width=1..4000.
Time-limited access tokens. For paid or private media, add an expiry and (optionally) a client IP or path prefix to the signed payload. CloudFront signed URLs and signed cookies do this with a key group; Cloudflare implements it with a Worker that validates a signed token before proxying, using the same verification primitives as any other edge auth check. The caching consequence matters: if the token is in the URL, every token is a distinct cache key and your hit ratio is zero. Put the token in a query parameter that is excluded from the cache key but still validated by the edge function, or validate a signed cookie and key only on the path.
Provider-specific implementation
Cloudflare Images and Image Resizing
Cloudflare offers two products that people conflate. Image Resizing transforms images that live on your origin, addressed by a /cdn-cgi/image/ path prefix or by the cf.image option inside a Worker fetch. Cloudflare Images is a hosted store: you upload the master, it lives in Cloudflare’s own storage, and you address named variants at imagedelivery.net.
The URL form is the quickest way to try resizing, and it works on any zone with the feature enabled:
https://www.example.com/cdn-cgi/image/width=840,quality=72,format=auto,fit=scale-down/assets/hero.jpg
format=auto is the built-in Accept negotiation: Cloudflare picks AVIF or WebP based on the client and keys the derivative accordingly without emitting a hit-ratio-destroying Vary. For anything beyond a fixed URL, the Worker form gives you the allow-list and the signing check in the same place:
const VARIANTS = {
thumb: { width: 240, quality: 70, fit: "cover", height: 240 },
card: { width: 640, quality: 74, fit: "scale-down" },
hero: { width: 1600, quality: 78, fit: "scale-down" },
};
export default {
async fetch(request) {
const url = new URL(request.url);
// /img/<variant>/<path...>
const [, , variant, ...rest] = url.pathname.split("/");
const options = VARIANTS[variant];
if (!options) return new Response("unknown variant", { status: 404 });
const accept = request.headers.get("accept") || "";
const format = accept.includes("image/avif")
? "avif"
: accept.includes("image/webp")
? "webp"
: "jpeg";
const origin = `https://media-origin.example.com/${rest.join("/")}`;
const res = await fetch(origin, {
cf: { image: { ...options, format }, cacheEverything: true, cacheTtl: 31536000 },
});
if (!res.ok) return new Response("not found", { status: 404 });
const headers = new Headers(res.headers);
headers.set("cache-control", "public, max-age=31536000, immutable");
return new Response(res.body, { status: 200, headers });
},
};
Cloudflare Images works differently: variants are defined once in the dashboard or API, and the delivery URL names one of them, which makes the allow-list structural rather than something you enforce in code.
curl -X POST \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/images/v1/variants" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":"card","options":{"fit":"scale-down","width":640,"metadata":"copyright"},"neverRequireSignedURLs":false}'
Delivery is then https://imagedelivery.net/<account_hash>/<image_id>/card — no arbitrary width can ever be requested.
AWS CloudFront with Lambda@Edge or S3 Object Lambda
CloudFront has no built-in resizer, so you choose where to put the compute. The classic pattern is a Lambda@Edge origin-response function: on a cache miss, CloudFront fetches from S3, the function transforms the body with sharp, and CloudFront stores the transformed response. Because it only runs on misses, the encode cost is bounded by your miss rate.
// Lambda@Edge, origin-response trigger, Node 20 runtime, layer with sharp
const sharp = require("sharp");
const VARIANTS = { thumb: 240, card: 640, hero: 1600 };
exports.handler = async (event) => {
const { request, response } = event.Records[0].cf;
if (response.status !== "200") return response;
const params = new URLSearchParams(request.querystring);
const width = VARIANTS[params.get("v")];
if (!width) return response; // not a transform request
const accept = request.headers["accept"]?.[0]?.value ?? "";
const wantsAvif = accept.includes("image/avif");
const wantsWebp = accept.includes("image/webp");
const input = Buffer.from(response.body, "base64");
let pipeline = sharp(input).rotate().resize({ width, withoutEnlargement: true });
let contentType = "image/jpeg";
if (wantsAvif) { pipeline = pipeline.avif({ quality: 55 }); contentType = "image/avif"; }
else if (wantsWebp) { pipeline = pipeline.webp({ quality: 78 }); contentType = "image/webp"; }
else { pipeline = pipeline.jpeg({ quality: 80, mozjpeg: true }); }
const out = await pipeline.toBuffer();
response.body = out.toString("base64");
response.bodyEncoding = "base64";
response.headers["content-type"] = [{ key: "Content-Type", value: contentType }];
response.headers["cache-control"] = [
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
];
return response;
};
The .rotate() call with no argument applies the EXIF orientation before resizing — omit it and a subset of phone photos arrive on their side. Note the 1 MB response-body ceiling for Lambda@Edge generated responses; anything larger has to be written to S3 and redirected to, which is exactly the shape S3 Object Lambda gives you natively. With an Object Lambda Access Point, CloudFront’s origin is the access point, your function receives a presigned URL for the master, and it streams the transformed object back through WriteGetObjectResponse without the size ceiling.
The cache policy must include the variant selector and the normalized format, and must not include anything else:
resource "aws_cloudfront_cache_policy" "images" {
name = "image-derivatives"
min_ttl = 1
default_ttl = 31536000
max_ttl = 31536000
parameters_in_cache_key_and_forwarded_to_origin {
enable_accept_encoding_brotli = false
enable_accept_encoding_gzip = false
query_strings_config {
query_string_behavior = "whitelist"
query_strings { items = ["v"] }
}
headers_config {
header_behavior = "whitelist"
headers { items = ["X-Img-Format"] } # bucketed by a CloudFront Function
}
cookies_config { cookie_behavior = "none" }
}
}
A lightweight CloudFront Function on the viewer-request event sets X-Img-Format to avif, webp or jpeg so the cache key carries three values instead of the raw Accept header’s dozens.
Fastly Image Optimizer
Fastly IO is query-parameter driven and runs inside the Fastly network, so there is no function to deploy. You enable it on the service, point it at a shielded backend holding the masters, and add parameters to existing URLs:
https://media.example.com/hero.jpg?width=840&quality=75&auto=webp&fit=bounds
auto=webp (or auto=webp,avif where enabled) performs the Accept negotiation. The important VCL work is bounding the parameter surface and stripping everything else from the hash so an attacker cannot fan out derivatives:
sub vcl_recv {
if (req.url.path ~ "^/img/") {
# allow only our four named widths; anything else is rewritten to the default
if (req.url.qs !~ "(^|&)width=(420|840|1280|1600)(&|$)") {
set req.url = querystring.set(req.url, "width", "840");
}
set req.url = querystring.filter_except(req.url,
"width" + querystring.filtersep() + "quality" + querystring.filtersep() + "auto");
set req.url = querystring.sort(req.url);
}
}
sub vcl_fetch {
if (req.url.path ~ "^/img/") {
set beresp.ttl = 1y;
set beresp.http.Cache-Control = "public, max-age=31536000, immutable";
unset beresp.http.Set-Cookie;
}
}
Fastly’s shielding is configured per backend and is essential here: the master fetch should hit one shield PoP, not all of them.
Self-hosted imgproxy or thumbor
When you need transforms your CDN does not offer, or you are on a provider without an image product, run the transformer yourself and put the CDN in front of it. imgproxy is the pragmatic default — a single Go binary, no runtime dependencies, mandatory URL signing, and native AVIF support.
docker run -d --name imgproxy -p 8080:8080 \
-e IMGPROXY_KEY="$IMGPROXY_KEY" \
-e IMGPROXY_SALT="$IMGPROXY_SALT" \
-e IMGPROXY_MAX_SRC_RESOLUTION=50 \
-e IMGPROXY_ALLOWED_SOURCES="s3://media-masters/" \
-e IMGPROXY_AVIF_SPEED=6 \
-e IMGPROXY_ENFORCE_AVIF=false \
-e IMGPROXY_TTL=31536000 \
darthsim/imgproxy:latest
A signed URL looks like /{signature}/rs:fit:840:0/q:72/f:avif/plain/s3://media-masters/hero.jpg. IMGPROXY_MAX_SRC_RESOLUTION caps the megapixels it will decode, which is your defence against a decompression-bomb upload; IMGPROXY_ALLOWED_SOURCES stops it being used as an open proxy. Put the CDN in front with a long TTL and the transformer never sees repeat traffic. thumbor is the older Python alternative with a richer filter set and smart-cropping via detectors, at the cost of a heavier deployment.
Platform comparison
| Provider | Mechanism | Wire behavior | Failover support |
|---|---|---|---|
| Cloudflare Image Resizing | /cdn-cgi/image/ path prefix or cf.image in a Worker |
format=auto negotiates AVIF/WebP internally, no Vary: Accept emitted |
On transform error the original bytes are passed through unchanged |
| Cloudflare Images | Named variants served from imagedelivery.net |
Fixed variant set; signed URLs optional per variant | Independent of your origin — masters are stored by Cloudflare |
| AWS CloudFront + Lambda@Edge | Origin-response function running sharp |
Rewrites Content-Type; you emit Vary or a bucketed header yourself |
Function error returns a 5xx unless you catch and pass the original through |
| AWS S3 Object Lambda | Access point transforms on GetObject |
Streams via WriteGetObjectResponse, no 1 MB body ceiling |
Falls back to the supporting access point if the function is bypassed |
| Fastly Image Optimizer | Query parameters on a shielded backend | auto=webp negotiation, Vary: Accept added by the platform |
Serves the untransformed original when IO cannot decode the source |
| imgproxy / thumbor (self-hosted) | Signed path-encoded transform URL behind the CDN | Whatever your service emits; you own Vary and Cache-Control |
Needs your own health checks and multi-AZ deployment |
Step-by-step rollout
- Inventory what you serve today. Pull a week of access logs, group image responses by path and content type, and record total bytes and request counts. You want the ten templates responsible for most of the bytes, not a list of every file.
- Move the masters behind a private origin. One high-resolution master per asset in a bucket the public cannot reach directly. Delete the pre-baked sizes only after the edge pipeline is live.
- Define the variant allow-list. Four widths covers almost every layout: 420, 840, 1280, 1600. Add a square crop if you have avatars. Name them, and make the transform layer reject anything not on the list.
- Deploy the transform layer in shadow mode. Serve it on a parallel hostname first (
img-next.example.com), compare outputs against the current files, and check EXIF orientation and transparency on a sample that includes phone photos and logos. - Enable an origin shield. Confirm that generating four widths of one master produces exactly one origin GET, not four.
- Set immutable freshness on derivatives.
Cache-Control: public, max-age=31536000, immutableon the transform output, with the source content hash in the URL so a new master is a new URL. - Update templates to emit
srcsetandsizes. Setwidth/height, addloading="lazy"below the fold only, andfetchpriority="high"on the LCP image. - Warm the important variants. Loop over your top pages and request every candidate in the
srcsetonce per format bucket so real users never pay a cold AVIF encode. - Verify on the wire. Confirm the format flips with
Accept, that the second request is a hit, and that the byte counts are what you expect:
# AVIF-capable client
curl -sI -H 'Accept: image/avif,image/webp,*/*' \
'https://www.example.com/img/card/hero.jpg' \
| grep -iE 'content-type|content-length|cf-cache-status|age|cache-control'
# legacy client
curl -sI -H 'Accept: image/*,*/*;q=0.8' \
'https://www.example.com/img/card/hero.jpg' \
| grep -iE 'content-type|content-length'
# rejected variant — should be a 404, not a fresh encode
curl -s -o /dev/null -w '%{http_code}\n' \
'https://www.example.com/img/w=843/hero.jpg'
Expected: content-type: image/avif on the first, image/jpeg on the second, 404 on the third, and cf-cache-status: HIT with a non-zero age when you repeat the first command.
- Measure, then cut over. Compare median image bytes per page view and origin egress before and after. Only when both move in the right direction do you point the production hostname at the new layer.
Cache key, TTL and propagation implications
Every axis you allow multiplies the derivative count. Four widths × three formats is twelve objects per master before you add a crop or a quality override; add DPR as its own parameter and it is thirty-six. The arithmetic is the whole design constraint, and it is why collapsing DPR into width and bucketing Accept are not micro-optimizations — they are what keeps the keyspace finite.
Freshness for derivatives should be effectively infinite. The transform output is a pure function of (master bytes, transform parameters), so if either changes the URL should change — put a content hash of the master in the path and set max-age=31536000, immutable, the same discipline described in Fingerprinting Assets for Immutable Caching. This matters more for images than for JS bundles because there are ten times as many objects, and re-encoding them all after a bad purge is genuinely expensive.
When a master genuinely must be replaced under the same URL — a legal takedown, a corrected product photo — you have to purge every derivative, not just the master. That is what surrogate keys are for: tag every derivative with the master’s asset ID and purge the tag, as described in Using Cache Tags for Surrogate-Key Purging. Purging by URL means enumerating twelve or more paths and getting every one right.
Propagation of the configuration is fast on all three major platforms but the warm-up is not. A cache-policy change on CloudFront or a new cache key on Cloudflare orphans the existing derivatives, so the first request for each variant pays a full decode-and-encode. On a catalog with 50,000 images that is a multi-hour thundering herd against your transform layer. Schedule key changes with a warming pass, and keep a stale-while-revalidate window on the derivatives so the herd degrades into background revalidation rather than user-visible latency.
Troubleshooting and rollback
| Symptom | Likely cause | Fix |
|---|---|---|
Every client gets the same format regardless of Accept |
An intermediate proxy or the CDN cached the first response without keying on the format bucket | Add the bucket to the cache key; confirm with two curl calls sending different Accept values |
| Hit ratio collapsed after enabling negotiation | Raw Vary: Accept in the key |
Bucket Accept into 2–3 values and key on the bucket instead |
| Origin egress went up, visitor bytes went down | Master re-fetched once per derivative | Enable tiered cache / origin shield and cache the origin fetch |
| Some phone photos render rotated 90° | EXIF orientation dropped when metadata was stripped | Apply orientation before stripping (sharp().rotate(), imgproxy handles it by default) |
| Logos have black backgrounds | Transparent PNG converted to JPEG | Route alpha-bearing sources to WebP/AVIF, or flatten onto an explicit background color |
| Transform layer CPU pegged, latency spikes | Unbounded width parameter or missing signature check | Enforce the named-variant allow-list; reject unsigned requests |
| 404 pages cached as image derivatives | Origin 404 body passed into the transform and stored with a long TTL | Check res.ok before transforming; give error responses a short TTL |
| Video stalls or buffers the whole file | Origin ignores Range, or an edge function buffered the body |
Verify Accept-Ranges: bytes at origin; stream the body, never read it into memory |
Rollback protocol. Because the transform layer sits in front of the masters, the fast revert is to bypass it rather than undo it. Point the image hostname’s route back at the origin bucket (or set the Worker route to none), which restores the pre-existing behavior in seconds without touching cached derivatives. Then purge the derivative prefix by surrogate key so no half-broken output survives. Keep the pre-baked sizes in the bucket for one full release cycle after cutover — deleting them is the change that makes rollback impossible.
Edge cases and gotchas
- Animated content. An animated GIF converted to a static WebP loses the animation silently. Detect the frame count and either keep it as-is, convert to animated WebP, or transcode to a muted looping MP4 (usually 10× smaller).
- SVG is not a raster. Do not send SVG through a resizer — it is text, so it belongs in the compression path with
Content-Encoding, not the transform path. - Color profiles. Stripping an embedded ICC profile from a wide-gamut photo shifts the colors on a P3 display. Convert to sRGB rather than dropping the profile.
- Upscaling.
withoutEnlargement(orfit=scale-down) prevents a 400 px master being blown up to 1600 px, which wastes bytes and looks worse than the original. - Decompression bombs. A 100-megapixel PNG that decodes to gigabytes of RGBA will kill an edge function. Cap source resolution and file size before decode.
Vary: Accepton error responses. A 404 or 500 that inherits theVaryheader multiplies your negative-cache entries by the number of format buckets.Content-Lengthon transformed range responses. Transforming a ranged response is nonsensical; passRangerequests straight through forvideo/*andaudio/*.- Preload and
srcsetmust agree. A<link rel="preload" as="image">without a matchingimagesrcset/imagesizesdownloads a second copy of the image at a different width. - Signed URLs in the cache key. If the signature covers a timestamp, every request is unique. Sign the transform parameters, not the clock, and put expiry in a key-excluded parameter.
Frequently Asked Questions
Should I pre-generate image sizes at build time instead of transforming at the edge? Pre-generation is simpler and perfectly reasonable for a small, fixed asset set where the images ship with the code. It stops scaling the moment content is user-generated or the design changes a breakpoint, because every change means re-running the whole pipeline and re-uploading. Edge transformation trades a little first-request latency for the ability to add a new width by editing one line of a template.
Does format=auto need a Vary: Accept header?
Not on a CDN that performs the negotiation internally — Cloudflare’s format=auto, for example, folds the format into its own cache key and deliberately does not emit Vary: Accept, because that header would fragment every downstream cache. If you implement negotiation yourself in a function, you do need either Vary or an equivalent bucketed key, otherwise a shared proxy will serve AVIF bytes to a client that cannot decode them.
How many widths should a srcset actually list?
Four to five is the practical sweet spot for most layouts. Fewer than three means large clients download significantly more than they need; more than six multiplies your derivative count and your warming cost for savings the user cannot perceive. Choose widths from your real breakpoints rather than a round-number ladder, and remember a 3× phone at a 320 px slot wants the 960-ish candidate.
Why did enabling image optimization increase my origin bandwidth?
Because each derivative that misses cache fetches the full master from origin. Twelve variants of a 4 MB photo is 48 MB of origin egress unless the master fetch is itself cached — enable an origin shield or set cacheEverything on the origin fetch, and confirm with origin logs that one master produces one GET rather than one per variant.