Cloudflare Polish vs Image Resizing: tradeoffs
Both features shrink images at Cloudflare’s edge, so teams reach for one, see a byte saving, and never evaluate the other — usually leaving the larger win on the table. This guide — part of Cloudflare Image Resizing and Polish within CDN & Edge Media Delivery — frames the choice honestly. Polish is free, automatic, and cannot resize; Image Resizing is billed, explicit, and derives any dimension you want. The right answer is often “both, on different images,” and this page shows how to draw that line by cost, plan, and image role.
Before you can compare them fairly
The one distinction that drives everything
Polish re-encodes the bytes an image already has; Image Resizing produces new bytes at a new size. That single difference cascades into every other tradeoff:
- Polish cannot make a 3000-pixel master smaller in dimensions, only in codec. The device still decodes 3000 pixels.
- Image Resizing produces the exact pixels the slot needs, which is where the order-of-magnitude payload savings live — but it charges per unique transformation and needs either a paid plan or a Worker.
If your images are already served near their display size (a CMS that exports at render width, avatars uploaded at 96 px), Polish captures nearly the whole opportunity for free. If your images are oversized masters dropped into responsive layouts, Polish leaves most of the waste in place and Resizing is the real fix.
Where each engine sits in the request path
The two features are not alternatives at the same layer — they attach to the request at completely different points, and that is why they never collide.
Polish is a response-path filter. The URL stays exactly what your HTML already asks for. On a cache miss the edge fetches the master from origin, and Polish re-encodes the bytes on the way into the cache. Three settings govern it: lossless strips metadata and repacks the existing codec without touching pixel values; lossy additionally re-encodes JPEG at a reduced internal quality target; and the WebP/AVIF conversion option layers a next-gen encode on top of either mode, gated on the request Accept header. Because the negotiated variants live under the same URL, the object is stored with Vary: Accept — the mechanism described in Cache-Control headers for image and video assets.
Polish also declines work, silently, and tells you so. If the WebP encode comes out larger than the source, or the source is already an optimally packed PNG, or the file is a format Polish does not handle, the original bytes pass through and the cf-polished header records the reason:
cf-polished value |
Meaning |
|---|---|
origFmt=jpeg, origSize=283946 |
Polish processed the object; the header reports the source format and byte count |
status=webp_bigger |
The WebP encode was larger than the original; the original was served |
status=webp_same |
The WebP encode saved nothing measurable; the original was served |
status=already_optimized |
The source was already at or below Polish’s re-encode target |
status=not_needed |
The object was too small or of a type Polish skips |
| (header absent) | Polish never ran — feature off, or the cached object predates enabling it |
Image Resizing is a request-path interceptor. The /cdn-cgi/image/ prefix is recognised before normal origin routing happens, so the edge never asks your origin for that path. It parses the comma-separated option string, issues its own internal subrequest for the master named in the tail of the URL, transforms the decoded pixels, and stores the derived variant under a key that includes the full option string. The cf-resized header appears only on responses that this path produced.
Decision matrix
| Dimension | Polish | Image Resizing |
|---|---|---|
| Changes pixel dimensions | No | Yes (width, height, dpr, fit) |
Auto WebP/AVIF from Accept |
Yes (WebP/AVIF toggle) | Yes (format=auto) |
| Configuration effort | One dashboard toggle | Per-image URL or a Worker |
| Plan requirement | Pro and up (dashboard toggle) | Paid tier for URL form, or any plan via Workers |
| Billing model | Included, no per-image charge | Billed per unique transformation |
Works on legacy <img src> unchanged |
Yes | No — the URL must become /cdn-cgi/image/… |
| Fixes oversized-image decode cost | No | Yes |
| Per-request crop / focal point | No | Yes (gravity, fit=cover) |
| Cache key | Original URL plus Vary: Accept |
URL including the full option string |
| Behaviour when it cannot process | Passes the original through, reason in cf-polished |
onerror decides: error, or redirect to the source |
| EXIF / metadata handling | Stripped as part of the re-encode | Controlled explicitly by metadata= |
| Animation control | Animation preserved as-is | anim=false collapses to the first frame |
| Point of attachment | Response path, after origin fetch | Request path, before origin routing |
| Diagnostic header | cf-polished |
cf-resized |
| Best for | Bulk of already-sized images | Heroes, galleries, responsive srcset |
Tradeoff: Polish’s zero-effort appeal is real, but its ceiling is low on sites whose images are oversized. On a page where a 2 MB master paints into a 500-pixel column, Polish might return 1.2 MB (WebP) while Resizing returns 40 KB. The 30× difference is not a codec difference — it is a dimensions difference Polish structurally cannot touch.
What the gap actually looks like in bytes
Put the three outcomes on one scale and the argument stops being philosophical. The measurements below are for a single 2000×1333 photographic master painting into a 640-pixel column:
| Stage | Delivered bytes | What actually changed | Pixels decoded |
|---|---|---|---|
| Origin master, untouched | 2,048 KB | nothing | 2.67 M |
| Polish, lossy + WebP | 1,200 KB | codec and quality only | 2.67 M |
Image Resizing, width=640,format=auto |
40 KB | dimensions and codec | 0.27 M |
Two details in that table matter more than the headline ratio. First, the Polish row is not a bad result in isolation — a 41% reduction for one dashboard toggle is excellent value per unit of effort, and it is what the codec comparison in AVIF vs WebP compression benchmarks predicts for a photographic JPEG. Second, the decoded-pixel column is the one that shows up in main-thread time. A 2.67-megapixel decode costs the same CPU whether the bytes arrived as JPEG or WebP; only the resize removes it. On low-end Android that decode is frequently the difference between an LCP inside and outside the 2.5-second threshold.
Cost and plan considerations
The two features price completely differently, and conflating them causes budget surprises:
- Polish carries no per-image cost. Once enabled on an eligible plan it re-encodes everything the zone serves, forever, at no marginal charge. Its “cost” is entirely the missed opportunity of not resizing.
- Image Resizing bills per unique transformation — a distinct combination of source plus option string, counted the first time it is generated. Deliveries from cache are free; it is the generation of a new variant that meters. This makes variant discipline financial, not just architectural.
// Cost intuition: transformations = unique (source × option-string) pairs.
// This snippet estimates monthly transforms from your breakpoint plan so you
// can price Image Resizing before enabling it.
const sources = 12000; // distinct master images across the site
const breakpoints = 4; // widths you actually emit in srcset (e.g. 320/640/960/1280)
const formats = 1; // format=auto counts once per width, negotiated internally
// Each master generates (breakpoints × formats) variants the first time each
// is requested. Continuous/interpolated widths would multiply this by 100s —
// which is exactly why clamping widths to a fixed set is a COST control.
const monthlyTransforms = sources * breakpoints * formats;
console.log(`~${monthlyTransforms.toLocaleString()} transformations to warm the cache`);
// ~48,000 — compare against your plan's included transform quota.
Warning: the fastest way to blow the transformation budget is to let client JavaScript pass continuous width values (window.innerWidth) straight into the URL. Every distinct pixel value is a new billed transform and a new cache entry. Snap widths to a fixed breakpoint set — the clamp pattern in Configuring Cloudflare Image Resizing URL parameters is a cost control as much as a cache one.
Three second-order costs are easy to miss when you model this. The Worker route is metered twice: the transformation is billed, and so is the Worker invocation that requested it, so a Worker that runs on every image request has a floor cost independent of how many transforms it triggers. Purges are expensive in the same currency — invalidating a master evicts every derived variant, and the next request for each one re-bills the transform, which makes aggressive cache-busting a recurring line item rather than a one-off. And a low hit rate quietly re-bills: variants evicted under cache pressure at a given colo are regenerated the next time that colo is asked, so a rarely-requested tail of rarely-requested widths can meter repeatedly across the year even though the option strings never changed. Polish has none of these characteristics, which is a genuine argument for leaving it to handle the rarely-requested tail.
When to combine both
Combining is the common production answer, drawn along image role:
- Image Resizing on the images where dimensions dominate the payload: the LCP hero, product galleries, anything wired into a responsive
srcsetandsizesset. These justify the per-transform cost because the savings are largest and the traffic is highest. - Polish left enabled underneath for the bulk of remaining images: user-uploaded avatars served at their natural size, blog inline images from a CMS that already exports at render width, legacy templates you have not migrated to
/cdn-cgi/image/. Polish captures the codec win on all of them for free, with no markup changes.
The two do not collide. Once a URL is a /cdn-cgi/image/ transformation, Image Resizing owns its format via format=auto and Polish does not re-touch it; everything not wrapped in a resizing URL still flows through Polish. You get resized-and-negotiated heroes and codec-optimized everything-else from one configuration.
Tradeoff: running both means two diagnostic headers to reason about (cf-resized versus cf-polished) and two mental models for TTL — Resizing variants take their TTL from the resizing request’s cache settings, Polish images inherit the origin’s Cache-Control. Keep master URLs content-hashed so both models stay safe under long TTLs, per Cache-Control headers for image and video assets.
Verifying which engine handled a response
Neither feature announces itself in the rendered page, so every claim above has to be checked at the header level before you trust a rollout.
# 1. Confirm Polish ran on an ordinary, untransformed URL.
# --compressed is deliberately omitted: it affects transfer encoding, not
# the image codec, and would confuse the byte comparison in step 3.
curl -sI -H 'Accept: image/avif,image/webp,*/*;q=0.8' \
'https://example.com/media/blog/diagram.png' \
| grep -iE 'content-type|cf-polished|cf-cache-status'
# cf-polished: origFmt=png, origSize=184203 -> Polish processed the object
# cf-polished: status=webp_bigger -> Polish ran and declined; original served
# (no cf-polished at all) -> Polish off, or the cached copy predates it
# 2. Confirm Image Resizing — and NOT Polish — owns the transformed URL.
# cf-resized present with cf-polished absent is the documented boundary.
curl -sI -H 'Accept: image/avif,image/webp,*/*;q=0.8' \
'https://example.com/cdn-cgi/image/width=640,format=auto/media/blog/diagram.png' \
| grep -iE 'content-type|cf-resized|cf-polished|cf-cache-status'
# 3. Prove the dimension win rather than the codec win, by measuring both
# responses. %{size_download} reports the body bytes actually received.
for u in \
'https://example.com/media/blog/diagram.png' \
'https://example.com/cdn-cgi/image/width=640,format=auto/media/blog/diagram.png'; do
curl -s -o /dev/null -H 'Accept: image/avif,image/webp,*/*;q=0.8' \
-w '%{size_download} bytes %{content_type} <- '"$u"'\n' "$u"
done
Warning: run step 1 twice. The first response after enabling Polish is frequently the un-polished object still sitting in the edge cache, and reading that as “Polish is broken” is the single most common false alarm. Purge the path, request it again, then read cf-polished.
Common mistakes
1. Enabling Polish and calling image optimization “done”
Symptom: payloads dropped ~40% but LCP barely moved on image-heavy pages.
Cause: Polish fixed the codec but not the oversized dimensions driving decode and transfer.
Fix: move hero and gallery images to Image Resizing with real width breakpoints; keep Polish for the rest.
2. Reaching for Image Resizing on an already-right-sized site
Symptom: a transformation bill for images that were already served at display size. Cause: paying per transform to shrink pixels that did not need shrinking. Fix: if masters already match their slots, Polish alone captures the codec win for free — skip Resizing there.
3. Expecting Polish to crop or focal-point
Symptom: need a square avatar from a rectangular upload; Polish does nothing.
Cause: Polish never changes geometry.
Fix: cropping requires fit=cover + gravity on Image Resizing.
4. Assuming the two features fight over format
Symptom: worry that Polish will “double-encode” a resized image.
Cause: misunderstanding the boundary.
Fix: they are mutually exclusive per response — /cdn-cgi/image/ URLs are handled by Resizing only; confirm with the cf-resized versus cf-polished header.
5. Unbounded variant growth on Image Resizing
Symptom: cache hit rate low, transform count climbing every month. Cause: continuous or per-user widths creating endless unique option strings. Fix: enumerate a small breakpoint set and clamp all width inputs to it.
FAQ
Do I need to purge the cache after enabling Polish?
Yes, if you want the change to show up immediately. Polish runs on the response path when an object is pulled from origin, so anything already stored at the edge keeps being served in its original form until it expires. Purge the image prefixes to force a re-fetch, then re-check cf-polished.
Can Polish output AVIF, and should I turn that on?
Polish emits AVIF when the WebP/AVIF conversion option is enabled and the client’s Accept advertises AVIF support. It is worth enabling, because the failure mode is benign: when the AVIF encode is not smaller than the source, Polish serves the original and records status=webp_bigger rather than shipping a regression. The cost is a slightly longer first-byte time on the cold path while the encode happens.
Can I use Image Resizing without a paid plan?
The /cdn-cgi/image/ URL form requires a paid plan, but cf.image on a Worker fetch() runs on any plan that has Workers. That route is also how you keep resizing URLs out of your HTML — the Worker rewrites requests server-side, which is the only practical option when the markup is generated by a CMS you do not control.
Related
- Cloudflare Image Resizing and Polish — the parent guide with Workers
cf.imageexamples and Polish setup - Configuring Cloudflare Image Resizing URL parameters — the full option string once you have chosen Image Resizing
- CDN & Edge Media Delivery — the cache-key and negotiation model both features implement
- Cache-Control Headers for Image and Video Assets — TTL and immutable-URL discipline for masters and derived variants
- Fastly VCL for Image Format Negotiation — the same negotiation problem solved by hand-written cache keys instead of a toggle
- AVIF vs WebP Compression Benchmarks — the codec numbers behind the “codec only” row of the byte comparison