Cloudflare Image Resizing and Polish

Cloudflare gives you two distinct edge image tools that people constantly confuse, and choosing wrong costs either money or missed compression. This guide is part of CDN & Edge Media Delivery and pins down exactly what each does: Polish re-encodes the image you already serve into WebP or AVIF with zero URL changes and no resizing, while Image Resizing derives arbitrary widths, crops, and formats from a master through a /cdn-cgi/image/ URL or a Worker’s cf.image options. Polish is a checkbox; Image Resizing is a transformation engine you invoke per request. They can run together, and knowing where one ends and the other begins is the whole game.

Concept & architecture

Polish: automatic re-encoding in place

Polish operates on the images your origin already serves at their existing URLs. When enabled in the dashboard, Cloudflare intercepts image responses, re-compresses them, and — in Lossy or WebP mode — can hand back a smaller WebP or AVIF to clients whose Accept header advertises support. There is no width change, no crop, no new URL: https://site.com/img/hero.jpg stays exactly that, but the bytes on the wire become image/webp for a Chrome client and stay image/jpeg for a client that cannot decode WebP. Polish reads Accept and negotiates format for you, and it sets a cf-polished response header describing what it did.

Polish has three settings. Off does nothing. Lossless re-compresses without discarding data — smaller PNGs, no quality change. Lossy applies quality reduction and is the mode most sites want for photographs. Independently, the WebP toggle (and AVIF, where available) lets Polish switch the delivered format based on Accept. Because Polish never resizes, it cannot fix an oversized image: a 4000-pixel master delivered into a 400-pixel slot is still 4000 pixels after Polish, just in a more efficient codec.

Image Resizing: derivations from a master

Image Resizing is the transformation engine. You express a derivation — width, height, fit mode, quality, format — and Cloudflare fetches the master, applies it at the edge, caches the result, and serves it. There are two ways to invoke it:

  1. The URL form: /cdn-cgi/image/<options>/<source>, where <options> is a comma-separated list like width=640,quality=75,format=auto and <source> is the path or absolute URL of the master.
  2. The Workers form: fetch(url, { cf: { image: { … } } }), which is the same engine driven programmatically, so you can compute options per request, add signed-URL checks, or gate formats behind flags.

Unlike Polish, Image Resizing requires either a paid plan tier for the URL form or a Worker, and it bills per unique transformation (not per delivery from cache). The distinction and the cost model are covered in depth in Cloudflare Polish vs Image Resizing: tradeoffs.

The diagram shows both paths and where format=auto reads the Accept header.

Cloudflare Polish and Image Resizing request paths Two paths from a browser request. The Polish path: an existing image URL is intercepted, re-encoded to WebP or AVIF based on Accept, and served with a cf-polished header, without resizing. The Image Resizing path: a /cdn-cgi/image/ URL is parsed into options, the master is fetched, resized and re-encoded with format=auto reading Accept, cached, and served with a cf-resized header. Browser sends Accept plain URL /cdn-cgi/image/ Polish re-encode in place Accept → WebP/AVIF no resize cf-polished Image Resizing parse options fetch master, resize format=auto → Accept cache per variant cf-resized Delivered bytes AVIF / WebP / JPEG right format per client

Where each one runs in the request pipeline

The two features do not merely differ in what they produce; they attach at different points in the edge request lifecycle, and that explains almost every surprising behaviour. A request arriving at a Cloudflare data centre passes through TLS termination, WAF and rate limiting, then the Workers runtime if a route matches, then the cache lookup, and only then an origin fetch.

Image Resizing intercepts before the cache lookup for the derivative. It is a subrequest engine, not a post-processing filter. The option string plus the source path form the cache key for the derived object, so width=640,quality=75,format=auto/img/hero.jpg is a first-class cacheable object in its own right. Only on a miss does Cloudflare issue an internal subrequest for the master, and that subrequest is itself served from the edge cache when the master is warm. The practical consequence: adding a new width to your srcset costs one transform and one cached master read, not a fresh origin round trip per variant.

Polish runs on the origin response as the object is written into the cache. That is why it needs no URL change and why it cannot touch a response Cloudflare declines to cache. A response carrying Cache-Control: no-store or private, or one that sets a cookie, never enters the cache, so Polish never sees it — the single most common reason cf-polished is missing on a zone where Polish is definitely switched on. It is also why the first request for an image after a purge returns the original format: Polish re-encodes asynchronously for some content, and the header will report status=webp_only_smaller or similar on the subsequent hit.

Parameter and benchmark reference

The numbers below come from a 3000×2000 photographic JPEG master (1.9 MB) delivered into a 640-pixel-wide slot, measured against a warm edge cache. “Polish only” leaves the image at native resolution; “Resizing” derives the 640-pixel variant. Byte figures are the delivered payload seen by the browser.

Path Delivered width Format (Chrome) Payload vs raw master Notes
No optimization 3000 px JPEG 1.9 MB oversized for a 640 px slot
Polish Lossy + WebP 3000 px WebP ~1.2 MB −37% smaller codec, still oversized dimensions
Polish Lossy + AVIF 3000 px AVIF ~0.9 MB −53% best codec, dimensions unchanged
Resizing width=640,format=auto 640 px AVIF ~46 KB −97% correct pixels + best codec
Resizing width=640,quality=60 640 px AVIF ~31 KB −98% aggressive quality for thumbnails
Delivered payload by optimization path, log scale Horizontal bars on a logarithmic axis. No optimization delivers 1.9 megabytes. Polish Lossy with WebP delivers 1.2 megabytes, 37 percent smaller. Polish Lossy with AVIF delivers 0.9 megabytes, 53 percent smaller. Image Resizing at width 640 with format auto delivers 46 kilobytes, 97 percent smaller, and at quality 60 delivers 31 kilobytes, 98 percent smaller. The three Polish bars stay within one decade of the master while both Resizing bars drop a full two decades. Delivered payload — 3000×2000 JPEG master into a 640 px slot No optimization 1.9 MB Polish Lossy + WebP 1.2 MB, −37% Polish Lossy + AVIF 0.9 MB, −53% Resizing 640, auto 46 KB, −97% Resizing 640, q=60 31 KB, −98% 10 KB 100 KB 1 MB logarithmic scale — each gridline is 10× the previous

Read the chart on its log axis and the ranking of levers is unambiguous. The three Polish bars sit inside a single decade of one another because they all still carry 3000×2000 pixels; only the two Resizing bars cross into the tens-of-kilobytes decade, and they get there mostly by removing pixels, not by picking a better codec. On this master, codec choice is worth roughly 2×; correct dimensions are worth roughly 20×. That ratio is typical for photographic content and is exactly why a Polish-only rollout plateaus after the first week.

Tradeoff: Polish alone is a large win for free but leaves the biggest lever — pixel dimensions — untouched. The 3000-pixel image is still decoded at 3000 pixels on the client even though it paints into 640, wasting decode time and memory. Resizing addresses dimensions; that is why the payload drops two orders of magnitude only in the Resizing rows.

The decode cost never appears in a payload number but it dominates interaction latency on mid-range hardware. A decoded 3000×2000 RGBA surface is about 24 MB of bitmap regardless of which codec delivered it; the 640×427 derivative is about 1.1 MB. On a gallery of twenty images that is the difference between 22 MB and 480 MB of decoded-image memory, which on a memory-constrained Android device means the browser evicts and re-decodes images as you scroll. AVIF makes this worse than WebP at equal dimensions, because AVIF decode is more CPU-intensive per pixel — a detail explored in the AVIF vs WebP compression benchmarks. Resizing is what makes aggressive AVIF safe.

Core option reference

Option Applies to Meaning
width / height Resizing Target dimensions in device pixels. Bound these to real breakpoints.
format=auto Resizing Negotiate AVIF/WebP/JPEG from the request Accept header.
quality Resizing Perceptual quality 1–100. ~75 photographic default; lower for thumbnails.
fit Resizing How the image fits the box: scale-down, contain, cover, crop, pad.
gravity Resizing Focal point for cover/cropauto, left, top, or 0.5x0.3 coords.
dpr Resizing Device-pixel-ratio multiplier applied on top of width.
sharpen Resizing 0–10 unsharp-mask strength to recover crispness lost in downscaling.
metadata Resizing none (default), copyright, or keep for EXIF handling.
anim Resizing false collapses an animated GIF/WebP to its first frame.
background Resizing Fill colour for fit=pad and for flattening transparency to JPEG.
onerror Resizing redirect sends the client to the unresized source when a transform fails.
compression=fast Resizing Trades a few percent of size for a much cheaper encode on cold variants.
trim Resizing Removes uniform borders before the resize is applied.
Polish: Lossy/Lossless Polish Whole-zone re-compression mode; no per-request control.
Polish: WebP Polish Enables Accept-driven WebP/AVIF swap on in-place images.

Two of these interact with billing in ways worth knowing before you use them. anim=false turns a multi-megabyte animated GIF into a single still frame, which is usually what a thumbnail grid actually wants and removes the most expensive class of transform from your bill. compression=fast is the right choice for rarely-requested variants that will be requested a handful of times, because the encode cost dominates when the cache hit count is low; leave it off for hero images whose derivative is served millions of times and where every byte is amortised.

Step-by-step implementation

Step 1 — Enable Polish (the zero-config win)

Polish needs no code. In the Cloudflare dashboard, under Speed → Optimization → Image Optimization, set Polish to Lossy and enable WebP. From that moment every image response your origin emits is re-encoded per Accept. Verify with a request that advertises WebP:

# -H forces an Accept header that advertises AVIF and WebP, like Chrome 121.
# A working Polish returns content-type: image/webp (or image/avif) plus a
# cf-polished header describing the original size and the saving.
curl -sI -H 'Accept: image/avif,image/webp,*/*;q=0.8' \
  https://yoursite.com/img/hero.jpg \
  | grep -iE 'content-type|cf-polished'
# Expected:
#   content-type: image/webp
#   cf-polished: origFmt=jpeg, origSize=1900000, status=webp_bigger? no ...

Warning: Polish only acts on images served from your origin with a cacheable response. Images already behind /cdn-cgi/image/ are handled by Image Resizing instead, and Polish will not double-process them. If cf-polished is absent, the response was uncacheable (check Cache-Control: no-store) or the file was already smaller in its original format.

Step 2 — Serve a resized, negotiated image via the URL form

For correct dimensions, switch to Image Resizing. The URL form works on any zone with the feature enabled:

<!--
  /cdn-cgi/image/ is intercepted at Cloudflare's edge. The options segment:
    width=640     derive a 640-device-pixel variant from the master
    quality=75    perceptual quality target
    format=auto   negotiate AVIF/WebP/JPEG from the Accept header
    fit=scale-down never enlarge beyond the master's intrinsic width
  The trailing /img/hero-master.jpg is the ORIGIN path of the master image.
-->
<img
  src="/cdn-cgi/image/width=640,quality=75,format=auto,fit=scale-down/img/hero-master.jpg"
  srcset="/cdn-cgi/image/width=640,quality=75,format=auto/img/hero-master.jpg 640w,
          /cdn-cgi/image/width=1280,quality=75,format=auto/img/hero-master.jpg 1280w,
          /cdn-cgi/image/width=1920,quality=75,format=auto/img/hero-master.jpg 1920w"
  sizes="(max-width: 700px) 100vw, 640px"
  width="640" height="400"
  alt="Hero product photograph"
  fetchpriority="high">

Step 3 — Drive resizing from a Worker for conditional logic

When you need per-request decisions — signed URLs, an AVIF kill-switch, path-based quality — a Worker calls the same engine through cf.image:

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    // Only transform image routes; pass everything else straight through.
    if (!url.pathname.startsWith('/img/')) return fetch(request);

    // Clamp width to an allowlist. Passing arbitrary client-supplied widths
    // straight through would mint a new cached variant (and a new billed
    // transform) for every pixel value an attacker or a buggy client sends.
    const requested = Number(url.searchParams.get('w')) || 640;
    const allowed = [320, 640, 960, 1280, 1920];
    const width = allowed.reduce((a, b) =>
      Math.abs(b - requested) < Math.abs(a - requested) ? b : a);

    const accept = request.headers.get('Accept') || '';
    // An explicit format choice lets us disable AVIF via env flag without a
    // redeploy of markup. format:'auto' would also negotiate for us.
    const format = (env.AVIF_ENABLED === 'true' && accept.includes('image/avif'))
      ? 'avif'
      : accept.includes('image/webp') ? 'webp' : 'baseline-jpeg';

    return fetch('https://origin.example.com' + url.pathname, {
      cf: {
        image: {
          width,
          quality: 75,
          format,
          fit: 'scale-down',       // never upscale
          sharpen: 1,              // recover a little crispness after downscale
          metadata: 'none',        // strip EXIF to shave bytes
        },
        cacheEverything: true,
        cacheTtl: 31536000,        // cache the derived variant for a year
      },
    });
  },
};

Tradeoff: the Worker form costs a Worker invocation on top of the transform, but it is the only way to clamp untrusted width input, sign URLs, or roll AVIF out gradually. For static markup with fixed breakpoints, the URL form in Step 2 is cheaper and simpler.

Step 4 — Combine Polish and Resizing deliberately

Polish and Resizing coexist, but understand the boundary: once a URL is a /cdn-cgi/image/ transformation, Resizing owns the format via format=auto, and Polish does not re-touch it. Leave Polish on for the broad set of images that are not wrapped in a resizing URL (user-uploaded avatars served directly, legacy <img src> tags you have not migrated), and use Resizing for the hero and gallery images where dimensions matter. The tradeoffs guide walks the decision in detail.

Step 5 — Sign the transformation URL so the option space is not public

The URL form has one structural weakness: the option string is user-editable. Anyone can request /cdn-cgi/image/width=1997/img/hero.jpg and mint a brand-new billed transform and cache entry. Clamping inside a Worker (Step 3) fixes it when your Worker builds the URL, but not when markup exposes /cdn-cgi/image/ paths directly. The durable fix is an HMAC over the option string, verified at the edge before the transform runs:

// Worker in front of the image route. Markup emits /img/hero.jpg?w=640&sig=…
// and only a signature generated with the shared secret is honoured.
const enc = new TextEncoder();

async function hmac(secret, message) {
  const key = await crypto.subtle.importKey(
    'raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  const mac = await crypto.subtle.sign('HMAC', key, enc.encode(message));
  // Hex, not base64: base64 needs URL-escaping and breaks copy/paste debugging.
  return [...new Uint8Array(mac)].map(b => b.toString(16).padStart(2, '0')).join('');
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const width = url.searchParams.get('w');
    const sig   = url.searchParams.get('sig');

    // Sign the exact tuple that becomes the cache key. Signing only the path
    // would let an attacker keep the signature and vary the width freely.
    const expected = await hmac(env.IMG_SIGNING_KEY, `${url.pathname}:${width}`);

    // Constant-time-ish comparison: length check first, then a bitwise diff,
    // so a timing oracle cannot recover the signature byte by byte.
    if (!sig || sig.length !== expected.length) return new Response('bad signature', { status: 403 });
    let diff = 0;
    for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
    if (diff !== 0) return new Response('bad signature', { status: 403 });

    return fetch('https://origin.example.com' + url.pathname, {
      cf: {
        image: { width: Number(width), quality: 75, format: 'auto', fit: 'scale-down' },
        cacheEverything: true,
        cacheTtl: 31536000,
      },
    });
  },
};

Warning: sign the tuple that ends up in the cache key, not just the path. A signature covering only /img/hero.jpg lets an attacker reuse it with any w value and re-open the exact hole you closed. If you rotate IMG_SIGNING_KEY, accept both the old and new key for one deploy cycle, otherwise every cached page still carrying old signatures serves 403s until it is regenerated.

Tradeoff: signing removes the ability to hand-edit a URL while debugging. Keep an unsigned path behind an internal-only hostname, or accept a bypass token gated on a header your WAF strips from public traffic.

Step 6 — Restrict which origins may be resized

By default Image Resizing will only fetch masters from the same zone. If you enable “Resize images from any origin” so a Worker can transform assets from an object store on another hostname, your zone becomes an open image proxy: anyone can point /cdn-cgi/image/width=100/https://someone-elses-site.example/huge.png at your account and bill you for the transforms. Keep the toggle off unless a Worker gates it, and if you must enable it, validate the source hostname against an allowlist in the Worker before calling fetch with cf.image. The same discipline applies on other platforms — Fastly VCL format negotiation has an equivalent open-proxy trap when a backend is chosen from a request header.

Parameter reference: fit and gravity

fit is the most misunderstood option because it changes both dimensions and cropping:

  • scale-down — resize down to fit within width×height, never up. Preserves aspect ratio. The safe default.
  • contain — resize to fit within the box, may upscale. Preserves aspect ratio; can produce a smaller-than-box result on one axis.
  • cover — fill the entire box, cropping overflow. Preserves aspect ratio; combine with gravity to choose what survives the crop.
  • crop — like cover but also shrinks the box to the exact width×height, hard-cropping.
  • pad — resize to fit and pad the remainder to the exact box with background colour.
How each fit mode places a landscape source in a square box Five side-by-side panels show the same 3:2 landscape source rendered into a square target box. scale-down shrinks it to fit and never upscales. contain fits it inside the box and will upscale a smaller source. cover fills the whole box and crops the horizontal overflow. crop hard-crops to the exact width and height. pad fits the image and fills the remaining top and bottom bands with the background colour. scale-down shrinks to fit never upscales safe default contain fits inside the box upscales if smaller aspect preserved cover fills the whole box crops the overflow pair with gravity crop output is exactly w×h hard crop, no padding box shrinks to fit pad fits, then pads background fills rest exact box, no crop dashed = source extent · solid = delivered pixels · grey = padding

Two of the five are traps in a responsive layout. contain upscales, so a 400-pixel avatar requested at width=1200 is delivered as a blurry 1200-pixel file that is larger than the original — always prefer scale-down unless upscaling is a deliberate product decision. And cover versus crop differ only in what happens to the box: cover keeps the box you asked for and discards the overflow, while crop also shrinks the box when the source cannot fill it, so the delivered dimensions may not match the width/height you wrote. If your CSS relies on a fixed aspect ratio, cover is the safer of the two, and the layout side of that contract is covered in mastering srcset and sizes.

The full option string, including dpr, onerror, anim, and background, is documented parameter-by-parameter in Configuring Cloudflare Image Resizing URL parameters.

Tradeoffs & edge cases

Tradeoff: Polish saves bytes but not decode work. Because it never resizes, a Polish-only pipeline still ships full-resolution pixels the device must decode and hold in memory. On image-dense pages this inflates INP even as the payload shrinks. Reserve Polish-only for images already served near their display size.

Warning: format=auto needs the Accept header to survive. If a Worker or an upstream proxy strips or overwrites Accept before the transform runs, format=auto falls back to serving the source format to everyone. Log request.headers.get('Accept') in the Worker when negotiation misbehaves.

Tradeoff: every unique option string is a separate billed transform and cache entry. width=640 and width=641 are two transforms. Standardize on a fixed breakpoint set and never interpolate widths from continuous client input — clamp to the allowlist as in Step 3.

Warning: Resizing does not read your origin’s Cache-Control for the derived variant. The derived object’s TTL comes from cacheTtl/cacheEverything (Worker) or the zone’s Browser Cache TTL, not from the master’s headers. Set the master path to be content-hashed so a new asset is a new URL rather than relying on invalidation. This is the same immutable-URL discipline described in Cache-Control headers for image and video assets.

Tradeoff: fit=cover with gravity=auto uses saliency detection, which is not free and not perfect. For product images where the subject must never be cropped out, prefer explicit gravity coordinates over auto.

Warning: a polished response can be cached by an intermediary that does not understand why. Polish varies the delivered format on Accept using Cloudflare’s internal cache-key variation, which downstream caches cannot see. A corporate proxy or an ISP cache that stores the WebP it received for a Chrome client and replays it to a Safari 14 client produces a broken image with no error anywhere in your telemetry. If you serve traffic that traverses such intermediaries, either keep formats explicit through <picture> and type attributes, or make sure the response carries an appropriate Vary so shared caches key correctly. The same hazard on other CDNs is spelled out in CloudFront cache policy for Vary: Accept negotiation.

Tradeoff: animated content is the most expensive thing you can transform. An animated GIF or WebP re-encoded frame-by-frame costs far more edge CPU than a still, and animated AVIF output is not universally decodable. For thumbnail grids set anim=false to take the first frame; for genuine motion, stop treating it as an image and ship a short muted video instead — the byte and battery savings are an order of magnitude beyond anything a still-image codec can offer.

Warning: Polish ignores SVG, and Resizing rasterises it. image/svg+xml is not a raster format, so Polish skips it entirely; compressing SVG is a job for Brotli at the origin, not for an image pipeline. Image Resizing will rasterise an SVG source into PNG/WebP/AVIF if you point a transform at one, which silently converts your crisp vector logo into a fixed-resolution bitmap. Exclude .svg from any blanket resizing rule.

Tradeoff: derived variants and Tiered Cache interact. With Tiered Cache or Cache Reserve enabled, a derivative missing at a lower-tier data centre is fetched from the upper tier rather than re-transformed, which is what you want. Without it, a newly published hero image can be transformed independently at many data centres for the same option string — correct, but you pay for each of those transforms.

Browser & CDN compatibility

Format decode is a browser property; Cloudflare only decides which format to hand over based on Accept.

Feature Safari 14 Safari 16 Chrome 85+ Firefox 93+ Edge 18+
Receives WebP from Polish/Resizing Yes Yes Yes Yes Yes
Receives AVIF from Polish/Resizing No Yes Yes Yes Yes (18+)
Sends image/avif in Accept No Yes Yes Yes Yes
srcset width selection on /cdn-cgi/image/ URLs Yes Yes Yes Yes Yes
fetchpriority honored on resized <img> Yes (15.4+) Yes Yes (102+) Yes (132+) Yes (102+)

Debugging & validation

Confirm what actually reached the browser

The two diagnostic headers are cf-polished (Polish acted) and cf-resized (Image Resizing acted). They are mutually exclusive for a given response.

# Ask for AVIF explicitly and inspect which engine handled the request and
# what content-type came back. cf-resized appears only on /cdn-cgi/image/ URLs.
curl -sI -H 'Accept: image/avif,image/webp,*/*;q=0.8' \
  'https://yoursite.com/cdn-cgi/image/width=640,format=auto/img/hero-master.jpg' \
  | grep -iE 'content-type|cf-resized|cf-cache-status'
# Expected:
#   content-type: image/avif
#   cf-resized: internal stats (full=…, n=…, q=…)
#   cf-cache-status: HIT

If content-type comes back as image/jpeg when you asked for AVIF, either format=auto was omitted from the option string or the Accept header did not reach the transform. If cf-cache-status: MISS persists across identical requests, your option string is varying (interpolated width) or the cache is being bypassed.

Verify Polish did not make things worse

# Polish occasionally finds the re-encoded output LARGER than the source
# (already-optimized images). cf-polished reports this; when it does,
# Cloudflare serves the original. Grep for the status token to confirm.
curl -sI -H 'Accept: image/webp,*/*' https://yoursite.com/img/logo.png \
  | grep -i cf-polished
# A "webp_bigger" or similar note means the original was kept — expected for
# small flat-colour PNGs where WebP has no advantage.

The cf-polished value is a comma-separated bag of fields rather than a single status, and the status= token is the one that tells you whether to act:

cf-polished field or status Meaning Action
origFmt=jpeg, origSize=1900000 The format and byte size Polish received from the origin Sanity-check that the origin is not already serving a derivative
status=webp_bigger The WebP re-encode came out larger; the original was served None — expected on small flat-colour PNGs and tiny icons
status=not_needed The source was already at or below the target efficiency None
status=webp_smaller (or an AVIF equivalent) A modern format was produced and delivered Confirm content-type matches
no cf-polished header at all Polish never ran on this response Check the response is cacheable and the content type is a raster image

A missing header is the interesting case. Work through it in order: is the content type a raster image Polish handles (not SVG); is the response cacheable (no no-store, private, or Set-Cookie); is the URL already a /cdn-cgi/image/ transform, in which case Resizing owns it; and is the request actually reaching Cloudflare rather than a development proxy. Nine times out of ten it is the cacheability check.

Watch the transform count, not just the bytes

Cache and billing regressions look identical from the browser: both show correct images. The signal is the ratio of unique transformations to delivered images.

# Enumerate exactly which option strings your HTML emits. If this prints more
# than your breakpoint count, something is interpolating widths at runtime.
curl -s https://yoursite.com/ \
  | grep -o '/cdn-cgi/image/[^"/]*' \
  | sort -u
# Expected: one line per breakpoint, e.g. width=640,quality=75,format=auto
# A rarely-requested tail of near-identical widths (639, 641, 642) is the failure mode.

Run the same check against a rendered page, not just the server HTML, if a client-side component builds image URLs from element.clientWidth — that is the single most common source of unbounded variant growth, and it is invisible in the source markup.

Compare payloads across formats

# Download the AVIF and WebP variants and compare bytes. -o writes the body;
# -w prints the transferred size so you can diff without opening the files.
for fmt in "image/avif" "image/webp" "image/jpeg"; do
  printf '%s: ' "$fmt"
  curl -s -H "Accept: $fmt" \
    'https://yoursite.com/cdn-cgi/image/width=640,format=auto/img/hero-master.jpg' \
    -o /dev/null -w '%{size_download} bytes\n'
done

Frequently asked questions

Does enabling Polish break my existing <picture> element with explicit AVIF and WebP sources?

No, but it makes it redundant for the sources it already covers. The browser picks a <source> by its type attribute before any request is made, so the URL it requests is already the AVIF or WebP one; Polish then finds a response it cannot improve and leaves it alone. What breaks is the reverse assumption — believing Polish will fix a <picture> whose only source is a JPEG at the wrong dimensions. It will not change the dimensions.

Why does format=auto return JPEG to a browser that clearly supports AVIF?

Three causes, in order of likelihood. The Accept header did not survive to the transform (a Worker or upstream proxy replaced it); the option string omitted format=auto on one of several srcset entries so only some widths negotiate; or the source is a format from which the encoder declined to produce a smaller AVIF. Log request.headers.get('Accept') inside the Worker to eliminate the first, which accounts for most reports.

Can I use Image Resizing on a Free plan?

Not through the /cdn-cgi/image/ URL form, which requires a paid plan. The cf.image options in a Worker are the escape hatch, and they call the identical engine — transformations are still billed, but the entitlement follows the Workers subscription rather than the zone plan. That is the practical reason most small deployments start with the Worker form even though the URL form is simpler.

How do I invalidate a resized variant after replacing the master?

You do not, if you have set things up correctly. Content-hash the master path so hero.a1b2c3.jpg becomes hero.d4e5f6.jpg on change; every derived option string then points at a new source and is a new cache object. Purging by URL is possible but you would have to purge every option-string permutation, and the derived object’s TTL is not governed by the master’s Cache-Control anyway — see cache-control headers for image and video assets.

Is quality=75 the right default?

For photographic content delivered as AVIF, 75 is conservative and 50–60 is usually indistinguishable at typical viewing sizes; AVIF’s quality scale is not comparable to JPEG’s, and a JPEG-calibrated instinct will make you over-spend bytes. For flat graphics, screenshots, and anything with text or hard edges, keep quality high or stay lossless — ringing artefacts around text are far more noticeable than photographic noise. Set quality per content class, not per site.

What happens when the master is missing or the origin errors? Without onerror, a failed transform surfaces as an error response and a broken image. With onerror=redirect, Cloudflare 302s the client to the unresized source URL, which degrades to the original image rather than to nothing. That is the right default for user-generated content, where a fraction of masters will always be malformed; it is the wrong default for a signed-URL setup, where the redirect target may itself be inaccessible.