Resizing Images On the Fly with a Cloudflare Worker
Put a Worker in front of the originals, parse a small, validated set of parameters from the URL (width from an allow-list, fit, format from Accept), call fetch(originUrl, { cf: { image: { β¦ } } }) to have Cloudflare Images resize it, and let the edge cache the result under the transformed URL so each variant is produced once per data centre.
Generating every variant at upload time β as in generating srcset variants at upload time β means storing widths nobody requests and back-filling every existing image when a designer adds a new layout. Edge resizing inverts that: store only the original, produce a variant the first time someone asks for it. The danger is the same flexibility turned against you: an open ?w= parameter lets anyone request a million unique widths, each a cache miss and a billable transformation. This page is part of responsive image delivery in media processing and delivery pipelines.
When to use this approach
- Layouts change often enough that a fixed upload-time ladder keeps going stale.
- Most uploads are viewed rarely, so pre-generating two dozen variants per upload wastes storage and processing.
- Originals already sit in R2 or another origin reachable from the Worker, and Image Transformations is enabled on the zone.
Prerequisites
- A Cloudflare zone with Images transformations enabled (Dashboard β Images β Transformations β enable for the zone).
- Originals in R2 exposed on a hostname the Worker can fetch, e.g.
originals.example.com, or any HTTPS origin; transformations fetch the source by URL. wrangler3.x and TypeScript with@cloudflare/workers-types.- An HMAC secret (
wrangler secret put IMG_SIGNING_KEY) if you want arbitrary parameters; the allow-list variant below needs none.
Why parameters must be bounded
Implementation
URLs look like https://img.example.com/w800/avatars/9c1f2a7e.jpg. The first path segment names a preset; everything after it is the originalβs key.
// worker.ts
export interface Env {
ORIGIN: string; // e.g. "https://originals.example.com"
}
type Fit = "scale-down" | "cover" | "contain";
interface Preset { width: number; height?: number; fit: Fit; quality: number }
// The ONLY transformations this Worker will perform.
const PRESETS: Record<string, Preset> = {
w160: { width: 160, fit: "scale-down", quality: 75 },
w320: { width: 320, fit: "scale-down", quality: 75 },
w640: { width: 640, fit: "scale-down", quality: 75 },
w800: { width: 800, fit: "scale-down", quality: 75 },
w1200: { width: 1200, fit: "scale-down", quality: 75 },
w1600: { width: 1600, fit: "scale-down", quality: 72 },
sq96: { width: 96, height: 96, fit: "cover", quality: 80 }, // avatars
sq256: { width: 256, height: 256, fit: "cover", quality: 80 },
};
const KEY_RE = /^[a-z0-9][a-z0-9/_-]{0,200}\.(jpe?g|png|webp|avif|heic)$/i;
function pickFormat(accept: string | null): "avif" | "webp" | undefined {
const a = accept ?? "";
if (a.includes("image/avif")) return "avif";
if (a.includes("image/webp")) return "webp";
return undefined; // undefined β keep the original format (JPEG/PNG)
}
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
const [, presetName, ...rest] = url.pathname.split("/");
const preset = PRESETS[presetName];
const key = rest.join("/");
if (!preset) return new Response("Unknown preset", { status: 400 });
if (!KEY_RE.test(key) || key.includes("..")) return new Response("Bad key", { status: 400 });
// Never let the image service fetch itself: a transformed URL as the source loops.
if (req.headers.get("via")?.includes("image-resizing")) {
return fetch(`${env.ORIGIN}/${key}`);
}
const format = pickFormat(req.headers.get("Accept"));
const res = await fetch(`${env.ORIGIN}/${key}`, {
cf: {
image: {
width: preset.width,
height: preset.height,
fit: preset.fit,
quality: preset.quality,
format,
metadata: "none", // strip EXIF, including GPS, from every variant
gravity: "auto", // smart crop for "cover" presets
anim: false, // first frame only for animated GIF/WebP
},
cacheEverything: true,
cacheTtl: 31_536_000,
},
});
if (!res.ok) {
// cf-resized carries the transformation error code when resizing failed.
const why = res.headers.get("cf-resized") ?? `origin ${res.status}`;
return new Response(`Image unavailable (${why})`, { status: res.status === 404 ? 404 : 502 });
}
const out = new Response(res.body, res);
out.headers.set("Cache-Control", "public, max-age=31536000, immutable");
out.headers.set("Vary", "Accept");
return out;
},
} satisfies ExportedHandler<Env>;
Line-by-line on the options that matter
- Presets, not parameters.
PRESETSis the whole API. Adding a width is a deploy; nobody on the internet can invent one. If you truly need arbitrary sizes, sign the parameters with an HMAC and verify it in the Worker β the same idea as rate limiting presigned URL issuance, applied to reads. fit: "scale-down"resizes only if the original is larger β the edge equivalent ofwithoutEnlargement."cover"crops to exactly the requested box and is right for avatars;"contain"letterboxes and is rarely what you want for photos.formatfromAccept, leftundefinedfor browsers that support neither, so they receive the original format.format: "auto"exists and does the same negotiation internally; being explicit makes behaviour testable.metadata: "none"strips EXIF, XMP and IPTC. The default keeps copyright fields, and some configurations keep more; for user uploads, GPS in a public thumbnail is a privacy incident.gravity: "auto"uses saliency detection to choose the crop centre forcover, which keeps faces in avatars instead of centring on a shoulder.cacheEverythingandcacheTtlmake the transformed response cacheable at the edge for a year. The cache key is the Workerβs request URL, which includes the preset, so each preset is cached independently; becauseVary: Acceptvaries the body, pair this with the normalised-format approach in serving AVIF and WebP with Accept header negotiation if your zoneβs cache does not split on it.- The
vialoop guard. If the origin hostname is itself routed through this Worker, the resize fetch would call the Worker again. Requests made by the image service carryVia: image-resizing; pass those straight through.
First request versus every other request
Configuration gotchas
cf-resized: err=9412 β the origin returned something that is not an image, commonly an HTML error page or a 403 from a private bucket. Fetch the origin URL directly with curl -I from outside; if it needs auth, sign the origin request or expose a read-only hostname that only the Worker can reach.
cf-resized: err=9402 β the image is too large. Transformations cap input size (around 70 MB and 100 megapixels, depending on format and plan). Downscale originals at upload, or reject them β the check belongs in validating image dimensions and pixel bombs server-side.
Every response is cf-cache-status: DYNAMIC. Worker subrequests are not cached unless you ask: cacheEverything: true plus a cacheTtl is required on the fetch, and the origin must not send Cache-Control: private or Set-Cookie.
HEIC uploads come back unchanged. Input format support for HEIC is limited; convert to JPEG at upload time (see converting HEIC images to JPEG in the browser for the client-side route) so the edge always receives a format it can decode.
Signing parameters when presets are not enough
Some products genuinely need arbitrary dimensions β a crop tool that stores a user-chosen rectangle, a design editor exporting at any size. Presets cannot express those, but an open parameter set is still unacceptable. The answer is to let your application server, which knows what a legitimate request looks like, sign the exact parameter string, and have the Worker reject anything unsigned or tampered with.
The signature is an HMAC-SHA256 over the canonical parameter string and the object key, truncated to 16 bytes and base64url-encoded into the URL: /t/w=517,h=390,fit=cover/sig=Qm9nβ¦/photos/9c1f.jpg. The Worker recomputes the HMAC with the shared secret using crypto.subtle.sign, compares it in constant time, and only then builds the cf.image options from the parameters. Because the signed string includes every parameter, changing w=517 to w=518 invalidates it, so the number of distinct variants is bounded by the number of URLs your own server chose to issue.
Keep presets as the default and use signed parameters only for the flows that need them. Presets are cacheable across users β every feed card at w800 is the same object β while signed crops are typically unique per user action, so they have lower hit rates and cost more per view. Rotate the signing secret by accepting two keys during a transition window, exactly as you would for any other HMAC-protected URL.
Monthly cost against upload-time generation
Verification
IMG=https://img.example.com
# Allowed preset: resized, AVIF for an AVIF-capable Accept, cached on the second hit.
curl -s -o /dev/null -D - -H 'Accept: image/avif,*/*' "$IMG/w800/photos/9c1f2a7e.jpg" \
| grep -Ei '^(content-type|cf-cache-status|cf-resized)'
curl -s -o /dev/null -D - -H 'Accept: image/avif,*/*' "$IMG/w800/photos/9c1f2a7e.jpg" \
| grep -i cf-cache-status
# content-type: image/avif / cf-cache-status: MISS
# cf-cache-status: HIT
# Unknown preset and traversal attempts are refused before any fetch.
curl -s -o /dev/null -w '%{http_code}\n' "$IMG/w801/photos/9c1f2a7e.jpg" # 400
curl -s -o /dev/null -w '%{http_code}\n' "$IMG/w800/../secrets/key.jpg" # 400
# Metadata is gone.
curl -s "$IMG/w800/photos/9c1f2a7e.jpg" | exiftool -GPSLatitude -Make -
Frequently Asked Questions
Is this different from Cloudflare Images (the storage product)?
Yes. Cloudflare Images stores originals and serves named variants from its own storage; transformations, used here, resize images fetched from any origin, including R2 and S3. Transformations fit a pipeline where originals already live in your own bucket and you want to keep them there.
Can I do the same on CloudFront?
Yes, with a Lambda@Edge origin-request function running Sharp, or with the AWS-provided serverless image handler solution. The design rules are identical: presets or signed parameters, strip metadata, cache per preset with a normalised format key.
How do I purge a variant after the original changes?
Do not change originals in place. Upload a replacement under a new key (include a version or content hash in it) and update the asset record; old variants age out untouched. If you must purge, purge by URL prefix for that asset across all presets.