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

  1. A Cloudflare zone with Images transformations enabled (Dashboard β†’ Images β†’ Transformations β†’ enable for the zone).
  2. 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.
  3. wrangler 3.x and TypeScript with @cloudflare/workers-types.
  4. 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

Open parameters versus an allow-list With an open width parameter, requests for widths 801, 802 and 803 each miss the cache and each trigger a paid transformation. With an allow-list, those requests are rounded up to 800 or rejected, and all hit the single cached 800-pixel variant. Every distinct URL is a distinct transformation open ?w= ?w=801 ?w=802 ?w=803 miss β†’ transform miss β†’ transform miss β†’ transform unbounded bill, 0% hit rate allow-list /w800/ /w800/ (card) /w800/ (grid) /w803/ one transform cache hit 400 rejected bounded variants, high hit rate
The URL grammar is your cost control: if it can express a value, someone will request it.

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. PRESETS is 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 of withoutEnlargement. "cover" crops to exactly the requested box and is right for avatars; "contain" letterboxes and is rarely what you want for photos.
  • format from Accept, left undefined for 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 for cover, which keeps faces in avatars instead of centring on a shoulder.
  • cacheEverything and cacheTtl make 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; because Vary: Accept varies 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 via loop 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 carry Via: image-resizing; pass those straight through.

First request versus every other request

Latency of first and subsequent requests for a variant The first request for a preset in a data centre fetches the original from the origin and transforms it, taking around 350 milliseconds. Later requests in that data centre are served from edge cache in around 20 milliseconds. Cold variant vs warm variant, one data centre first request fetch 3 MB original decode + resize + AVIF ~350 ms later requests ~20 ms β€” edge cache hit Cold cost is paid once per preset per data centre, not per viewer. Warm the presets a new upload will certainly need (feed card, avatar) by requesting them once from the processing job; leave the rest to demand. Keep originals in the same region as your busiest users β€” the origin fetch dominates cold latency.
On-demand resizing moves the processing cost from upload time to the first view, and caching makes it a one-off.

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

Variants produced per month under each strategy For 100 thousand uploads a month with eight presets, upload-time generation produces 800 thousand variants regardless of views. On-demand produces about 310 thousand unique transformations, because most uploads are only ever requested at two presets. 100k uploads/month, 8 presets at upload 800k variants β€” every preset, every upload on demand β‰ˆ310k β€” only what gets requested On demand wins when most uploads are seen in only one or two layouts. Upload-time wins when every upload appears everywhere, or latency on first view is unacceptable. The hybrid β€” pre-warm two presets, leave six to demand β€” usually beats both.
Transformations billed per unique variant make the view distribution, not the upload count, the thing to measure.

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.