Resizing and Reformatting Images at the Edge
After working through this guide you will be able to put a transform layer in front of an origin bucket that holds a single master per asset, expose it through a small allow-list of named variants, emit srcset markup that maps one-to-one onto those variants, cache every derivative immutably, and warm the ones your key pages depend on before real traffic arrives.
The temptation with any edge image API is to expose the raw parameters — ?width=, ?height=, ?quality= — because it is one line of code and infinitely flexible. Do not. An open width parameter turns your transform layer into a CPU faucet anyone can open, fragments the derivative cache into thousands of near-identical objects, and makes the storage bill impossible to forecast. A closed set of named variants costs you an extra lookup table and removes an entire class of incident. Everything below assumes that constraint.
Key implementation objectives:
- Serve masters from a bucket the public cannot reach, with the transform layer as the only path in.
- Express every transform as a named variant (
thumb,card,hero) rather than free-form dimensions. - Emit
srcset/sizeswhose candidate URLs are exactly the named variants, so browser selection and cache keys stay aligned. - Cache derivatives with
immutablefreshness and warm the critical ones so no user pays for a cold AVIF encode.
Prerequisites and environment setup
You need Node 20 or newer, Wrangler 3.60+, and an object store holding one high-resolution master per asset — this guide uses R2, but any S3-compatible bucket works. The bucket must not be publicly readable; the transform Worker is the only path to the bytes. Confirm the toolchain and your auth:
node --version # v20.x or later
npx wrangler --version # 3.60.0 or later
npx wrangler whoami # account + token scopes
npx wrangler r2 bucket list
Create the bucket and upload a master keyed by a content hash so the URL changes whenever the image does:
npx wrangler r2 bucket create media-masters
HASH=$(sha256sum ./hero.jpg | cut -c1-16)
npx wrangler r2 object put "media-masters/${HASH}.jpg" --file ./hero.jpg
echo "master key: ${HASH}"
Then declare the binding and the route in wrangler.jsonc. Image Resizing must be enabled on the zone (Speed → Optimization) for the cf.image options to take effect; without it the Worker still runs but returns the master untouched, which is a quiet failure worth checking for.
{
"name": "image-transform",
"main": "src/index.ts",
"compatibility_date": "2025-01-15",
"routes": [{ "pattern": "img.example.com/i/*", "zone_name": "example.com" }],
"r2_buckets": [{ "binding": "MASTERS", "bucket_name": "media-masters" }],
"vars": { "DERIVATIVE_TTL": "31536000" }
}
Step-by-step procedure
Step 1 — Define the variant table
The variant table is the contract between your templates and your edge. Keep it small, keep it in one file, and derive both the Worker’s behavior and your template helper from it. Three or four widths plus one square crop covers the overwhelming majority of real layouts.
export const VARIANTS = {
thumb: { width: 240, height: 240, fit: "cover", quality: 68 },
card: { width: 640, fit: "scale-down", quality: 74 },
wide: { width: 1280, fit: "scale-down", quality: 76 },
hero: { width: 1600, fit: "scale-down", quality: 78 },
} as const;
export type VariantName = keyof typeof VARIANTS;
export const VARIANT_WIDTHS: Record<VariantName, number> = {
thumb: 240, card: 640, wide: 1280, hero: 1600,
};
Side effect worth naming: because the table is the single source of truth, adding a width later means one edit plus a warming pass — not a bucket migration.
Step 2 — Write the transform Worker
The Worker parses /i/{variant}/{key}, rejects unknown variants before touching the origin, picks a format from Accept, and asks the platform to transform the master. The cf.image options do the decode/resize/encode inside Cloudflare’s network, so the Worker never holds pixel data in memory.
import { VARIANTS, VariantName } from "./variants";
interface Env {
MASTERS: R2Bucket;
DERIVATIVE_TTL: string;
}
function pickFormat(accept: string): "avif" | "webp" | "jpeg" {
if (accept.includes("image/avif")) return "avif";
if (accept.includes("image/webp")) return "webp";
return "jpeg";
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const match = /^\/i\/([a-z]+)\/([A-Za-z0-9_-]+\.(?:jpg|png|webp))$/.exec(url.pathname);
if (!match) return new Response("bad request", { status: 400 });
const [, name, key] = match;
const opts = VARIANTS[name as VariantName];
if (!opts) return new Response("unknown variant", { status: 404 });
const format = pickFormat(request.headers.get("accept") ?? "");
// The cache key carries the variant and the format bucket — never the raw Accept header.
const cacheKey = new Request(`${url.origin}/i/${name}/${key}#${format}`, { method: "GET" });
const cache = caches.default;
const cached = await cache.match(cacheKey);
if (cached) return cached;
const object = await env.MASTERS.get(key);
if (!object) return new Response("no such master", { status: 404 });
// Hand the master body to the platform transformer.
const transformed = await fetch(
new Request(`${url.origin}/__master/${key}`, { method: "GET" }),
{
cf: { image: { ...opts, format, metadata: "copyright" } },
}
).catch(() => null);
const body = transformed && transformed.ok ? transformed.body : object.body;
const type = transformed && transformed.ok
? transformed.headers.get("content-type") ?? `image/${format}`
: (object.httpMetadata?.contentType ?? "application/octet-stream");
const response = new Response(body, {
status: 200,
headers: {
"content-type": type,
"cache-control": `public, max-age=${env.DERIVATIVE_TTL}, immutable`,
"x-img-variant": name,
"x-img-format": format,
},
});
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
},
};
Two details carry weight. The cache key appends the format as a URL fragment, which the Cache API treats as part of the key but which never reaches the network — that is how you get per-format variants without Vary: Accept. And the fallback path returns the untransformed master rather than a 5xx when the transformer errors, so a decode failure degrades to “slightly too large image” rather than a broken page.
On CloudFront the same logic lives in a Lambda@Edge origin-response function using sharp, with one extra line that the Worker gets for free — sharp(input).rotate() must be called explicitly before .resize() so EXIF orientation is applied rather than discarded:
const out = await sharp(input)
.rotate() // apply EXIF orientation first
.resize({ width, withoutEnlargement: true })
.avif({ quality: 55 })
.toBuffer();
Deploy and confirm the route bound:
npx wrangler deploy
npx wrangler tail --format pretty
Step 3 — Generate srcset from the same table
Because the variant table already holds the widths, the markup helper is mechanical. Generating it from the table — rather than hand-writing candidate lists in templates — is what guarantees no template ever requests a variant the Worker will reject.
import { VARIANT_WIDTHS } from "./variants";
const LADDER = ["card", "wide", "hero"] as const;
export function imgTag(key: string, sizes: string, alt: string, lcp = false) {
const srcset = LADDER
.map((v) => `/i/${v}/${key} ${VARIANT_WIDTHS[v]}w`)
.join(",\n ");
return `<img
src="/i/card/${key}"
srcset="${srcset}"
sizes="${sizes}"
width="1600" height="900"
alt="${alt}"
${lcp ? 'fetchpriority="high"' : 'loading="lazy" decoding="async"'}>`;
}
The rendered output for a hero image on a two-column layout:
<img
src="/i/card/9f2ac41b0d3e77a1.jpg"
srcset="/i/card/9f2ac41b0d3e77a1.jpg 640w,
/i/wide/9f2ac41b0d3e77a1.jpg 1280w,
/i/hero/9f2ac41b0d3e77a1.jpg 1600w"
sizes="(max-width: 700px) 100vw, 60vw"
width="1600" height="900"
alt="Fiber patch panel in an edge cabinet"
fetchpriority="high">
Note what is not here: no <picture>, no type="image/avif" sources. Format is negotiated server-side from Accept, so the markup only expresses width. If you would rather keep format in markup and avoid header negotiation entirely, that trade-off is laid out in Negotiating AVIF and WebP with Accept Headers.
Step 4 — Lock down freshness on the derivatives
The derivative is a pure function of the master bytes and the variant name, and the master key is a content hash, so the derivative can never legitimately change under the same URL. That justifies the strongest freshness directive available:
Cache-Control: public, max-age=31536000, immutable
immutable is the part that matters for repeat visits — without it, browsers revalidate on reload and you pay a round trip per image for nothing. Pair it with a Cache Rule so the edge keeps the object even if a future origin change gets sloppy:
curl -X POST \
"https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/rulesets/phases/http_request_cache_settings/entrypoint/rules" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"action": "set_cache_settings",
"expression": "(starts_with(http.request.uri.path, \"/i/\"))",
"action_parameters": {
"cache": true,
"edge_ttl": { "mode": "override_origin", "default": 31536000 },
"browser_ttl": { "mode": "override_origin", "default": 31536000 },
"cache_key": { "custom_key": { "query_string": { "include": [] } } }
}
}'
The empty query-string include list is deliberate: nothing in the query string affects the response, so nothing from it belongs in the key. The broader reasoning behind that choice lives in Cache Key & Vary Configuration.
Step 5 — Warm the variants that matter
Cold AVIF encodes on the critical path are the single most common reason an image migration ships and then gets reverted: the metrics look worse for the first hour because every LCP image is being encoded live. Warm them.
#!/usr/bin/env bash
set -euo pipefail
# usage: warm.sh keys.txt (one master key per line)
HOST="https://img.example.com"
for key in $(cat "$1"); do
for variant in card wide hero; do
for accept in "image/avif,image/webp,*/*" "image/webp,*/*" "image/*,*/*;q=0.8"; do
curl -s -o /dev/null -H "Accept: ${accept}" "${HOST}/i/${variant}/${key}"
done
done
done
echo "warmed $(wc -l < "$1") masters × 3 variants × 3 format buckets"
Run it from a machine close to the PoPs your users hit, or better, run it from several regions — the edge cache is per-PoP unless you have enabled tiered caching and an origin shield, in which case one warming pass populates the upper tier and every PoP fills cheaply from there.
Verification
Confirm the format flips with Accept, that the second request is a hit, and that the returned image is actually the pixel dimensions you asked for.
# 1. AVIF-capable client — expect image/avif and the variant echo header
curl -sI -H 'Accept: image/avif,image/webp,image/*,*/*;q=0.8' \
https://img.example.com/i/card/9f2ac41b0d3e77a1.jpg \
| grep -iE 'content-type|content-length|cache-control|x-img-|cf-cache-status|age'
content-type: image/avif
content-length: 41218
cache-control: public, max-age=31536000, immutable
x-img-variant: card
x-img-format: avif
cf-cache-status: HIT
age: 812
# 2. Legacy client — same URL, different bytes
curl -sI -H 'Accept: image/*,*/*;q=0.8' \
https://img.example.com/i/card/9f2ac41b0d3e77a1.jpg \
| grep -iE 'content-type|content-length'
content-type: image/jpeg
content-length: 104773
# 3. Prove the pixel dimensions, not just the byte count
curl -s -H 'Accept: image/avif,*/*' \
https://img.example.com/i/card/9f2ac41b0d3e77a1.jpg -o /tmp/card.avif
identify -format '%wx%h %m\n' /tmp/card.avif # ImageMagick
# or, without ImageMagick:
python3 -c "from PIL import Image; im=Image.open('/tmp/card.avif'); print(im.size, im.format, im.mode)"
640x360 AVIF
# 4. Unknown variant must be rejected, not encoded
curl -s -o /dev/null -w '%{http_code}\n' https://img.example.com/i/w843/9f2ac41b0d3e77a1.jpg
404
Watch wrangler tail during the first two calls: you should see exactly one R2 get per master per format bucket, and nothing at all on the repeat requests.
Troubleshooting
Unbounded variant explosion
Symptom: storage and transform CPU climb without a matching traffic increase, and the cache hit ratio slides week over week. Diagnose by grouping a day of request logs by path segment and counting distinct values — a healthy deployment has four, a broken one has thousands.
awk '{print $7}' access.log | grep '^/i/' | cut -d/ -f3 | sort | uniq -c | sort -rn | head -20
If that list has more than your allow-list, some path is bypassing the gate — usually a legacy rewrite rule or a CDN page rule that forwards raw query parameters to the transformer. Fix the gate first, then purge the derivative prefix so the orphaned objects stop consuming storage.
An upstream 404 cached as a derivative
Symptom: a broken image persists for the full TTL even after the master is uploaded. The cause is a transform layer that treated a 404 body as an image, encoded it, and stored the result with max-age=31536000. The Worker above avoids this by checking the R2 object exists before transforming and by never applying the immutable header to an error path. If you have already cached one, purge by URL and add the guard:
if (!object) {
return new Response("no such master", {
status: 404,
headers: { "cache-control": "public, max-age=30" },
});
}
Thirty seconds of negative caching absorbs a burst of retries without pinning a mistake for a year.
EXIF rotation lost
Symptom: a fraction of user-uploaded photos — almost always from phones held in portrait — render rotated 90°. JPEG stores orientation in an EXIF tag rather than in the pixel data, and any pipeline that strips metadata before applying it produces a sideways image. Reproduce it locally:
identify -format '%[EXIF:Orientation]\n' original.jpg # e.g. 6
identify -format '%wx%h\n' original.jpg /tmp/card.jpg # compare aspect
The fix is ordering, not configuration: call .rotate() with no arguments before .resize() in sharp, or set metadata: "copyright" on Cloudflare (which keeps orientation handling intact while dropping GPS and camera data). Stripping all metadata with metadata: "none" is what loses it.
Transparency lost converting to JPEG
Symptom: logos and UI sprites gain a black or white rectangle. JPEG has no alpha channel, so any PNG or WebP with transparency that lands in the JPEG fallback is composited onto a default background. Two mitigations, in order of preference: route alpha-bearing sources away from the JPEG bucket entirely (fall back to PNG rather than JPEG when the source has alpha), or set an explicit background matching your page so the failure is at least invisible.
const hasAlpha = /\.png$/i.test(key) || object.httpMetadata?.contentType === "image/webp";
const fallback = hasAlpha ? "png" : "jpeg";
const format = accept.includes("image/avif")
? "avif"
: accept.includes("image/webp")
? "webp"
: fallback;
Origin bandwidth went up after enabling transforms
Symptom: visitor bytes fell as expected, but egress from the bucket rose sharply. Every derivative that misses cache pulls the full master, so twelve variants of a 4 MB photo is 48 MB of origin reads instead of 4 MB. Confirm it from the origin side by counting GETs per master key over an hour; if the count matches your variant count rather than sitting near one, the master fetch is not being cached.
The fix has two halves: enable an origin shield so only one PoP talks to the bucket, and cache the master fetch itself (cacheEverything: true with a long cacheTtl on the origin subrequest, or an R2 binding as used above, which reads from the same colo). After the change, one master should produce one bucket read per shield PoP, not one per variant per PoP.
Frequently Asked Questions
Why not just accept a width query parameter and be done with it?
Because there is no upper bound on the number of distinct values a client can send, and every distinct value is a new cache object plus a new encode. An open width parameter is simultaneously a cost amplification vector, a cache-fragmentation bug, and a way for anyone to keep your transform layer busy. Named variants cap all three at the size of your table.
How many named variants should I define? Four widths plus one square crop handles most sites. The test is whether a browser ever picks a candidate more than about 40% wider than the slot it fills — if so, add a rung; if two adjacent rungs are almost never both chosen, remove one. Every rung you add multiplies by the number of format buckets, so the marginal cost is higher than it looks.
Should the master key be a content hash or a filename?
A content hash, always. It makes derivatives genuinely immutable, so you can set a one-year max-age without ever needing to purge, and it makes replacing an image a deploy rather than a cache-invalidation exercise. Keep the human-readable filename in your database if you need it for downloads; do not put it in the transform URL.
Do I need <picture> if the edge negotiates format from Accept?
No — server-side negotiation and <picture> are two answers to the same question, and running both means writing three times the markup for no additional benefit. Use srcset for width only, let the edge choose the format, and keep <picture> for genuine art direction where the crop differs between breakpoints.
What happens to the transform layer during a bucket outage?
Already-cached derivatives keep serving for their full TTL, which with a one-year immutable lifetime means most traffic is unaffected. Cold variants will fail, so return the closest cached variant or a 404 rather than a 5xx, and consider a stale-if-error window so a brief bucket blip never surfaces as a broken image — the pattern is covered in Serving stale-if-error During an Origin Outage.