LCP Regression After Cloudflare Polish WebP on Next.js

LCP doubled after enabling Cloudflare Polish on a Next.js site? Here is why double-encoding hurts and the bypass rule that brings LCP back under 2 seconds.
PerformanceLCPCloudflare
June 5, 20265 min read961 words

The Problem

I turned on Cloudflare Polish (Lossy + WebP) for a Next.js 16 client site to improve image delivery. The next morning, the PageSpeed Insights field data for the homepage moved from 1.9s LCP to 3.4s LCP. Mobile was worse: 4.2s on slow 4G in the lab. The product team rolled back the marketing campaign because the hero image now took 2.5 seconds to fully render on the 75th percentile.

The hero is a next/image with priority and a properly sized sizes value. Direct request to the origin returned a 38KB WebP in 180ms. Direct request through the Cloudflare zone returned an 82KB WebP in 1.4s on a cold edge cache, 600ms on a warm one. Same image. Worse on every axis.

curl -w "size:%{size_download} time:%{time_total}\n" -o /dev/null -s \
  https://origin.example.com/_next/image?url=%2Fhero.jpg&w=1920&q=75
# size:38432 time:0.182

curl -w "size:%{size_download} time:%{time_total}\n" -o /dev/null -s \
  https://example.com/_next/image?url=%2Fhero.jpg&w=1920&q=75
# size:82104 time:1.412

The Cloudflare response headers gave it away:

cf-polished: status=webp_bigger, original_size=38432

Polish refused to serve its own conversion because the original was already smaller, and the conversion attempt still cost transit time on every uncached variant. The double-processing was the issue.

Why It Happens

Next.js Image Optimization already produces WebP from your originals, sized per breakpoint, with quality tuned to the q parameter. The output is small and content-type correct. Cloudflare Polish is designed to do the same job for sites whose origin serves un-optimised images. When both run, three things go wrong at once.

  1. Polish receives an already-optimised WebP. Its lossy pass cannot improve a file the encoder already squeezed, so the cf-polished: status=webp_bigger header trips and the original passes through, but only after Polish has decoded, attempted re-encode, and compared sizes on the edge.
  2. The Next.js image route varies on the w and q query parameters. Polish's edge cache keys on URL plus Accept, which means each viewport size is a fresh Polish attempt on the first request from each region.
  3. The Vercel-hosted _next/image endpoint already sets long-lived cache headers, so Cloudflare layered on top adds a second cache that mostly misses on first paint and never helps on Polish-rejected variants.

The cumulative cost is the extra hop, the decode-and-compare overhead, and the missed-on-first-paint penalty that the Chrome User Experience Report picks up as field-data LCP regression. Lab data on a warm edge cache hides it. Field data does not.

The Cloudflare Polish documentation notes that Polish may not help origins that already serve optimised images but does not single out the Next.js image route. It should.

The Fix

Stop Polish from touching the Next.js image route. Two ways, depending on your Cloudflare plan.

Option 1: Configuration Rule (Pro and above). Add a rule that disables Polish for the optimised image path and keeps Cloudflare's edge cache layered on top:

When incoming requests match...
  URI Path: starts with "/_next/image"

Then the settings are...
  Polish: Off
  Cache Level: Cache Everything
  Edge TTL: 1 year

Cache Everything plus a long Edge TTL keeps Cloudflare caching the Next.js-optimised output, which is the part you actually want. Polish skips the path entirely, so no decode-encode-compare round-trip happens.

Option 2: Page Rule (Free plan). Page Rules cannot disable Polish directly, but they can disable apps and bypass the regular cache logic with Cache Everything:

URL: example.com/_next/image*
Settings:
  Disable Apps
  Cache Level: Cache Everything
  Edge Cache TTL: 1 month

On the Free plan you cannot turn Polish off per-path through a Configuration Rule, so the workaround is Disable Apps plus the long Edge Cache TTL. That keeps Cloudflare caching while removing the Polish pass on the image path. If your Vercel project has Image Optimization on its built-in CDN already, this is acceptable for hero images and worth the trade-off for the LCP win.

Verify with a fresh field-data sample. Polish caches at the edge persist for hours after the rule change. Force a new sample with a query-string buster on the hero image deployment, then watch the Chrome UX Report endpoint for your origin over the next 28 days. Lab tools will show the change in minutes; field data will catch up over a week or two.

# Confirm Polish is off for the route.
curl -I https://example.com/_next/image?url=%2Fhero.jpg&w=1920&q=75 | grep -i polish
# (no cf-polished header should appear)

If the cf-polished header still shows up, the rule is not matching, most often because the path uses a different prefix in a custom deployment, or because a higher-priority Page Rule is overriding the new one.

The Lesson

Cloudflare Polish is a great fit for sites with un-optimised image origins. It is a bad fit on top of Next.js Image Optimization, where every Polish pass is a redundant decode that costs LCP without saving bytes. Bypass _next/image from Polish, keep Cloudflare's edge cache for the optimised output, and let one optimisation pipeline own the asset.

If your LCP regressed after a CDN or image-pipeline change and the root cause is buried in stacked optimisations, that is a project I get paid to untangle. See my services. For a related LCP regression after a Next.js image config change, read LCP preload wrong image fix.

Stuck on a Core Web Vitals regression after a CDN change? Get it shipped.

Back to blogStart a project