CDN & Edge Media Delivery

Pushing image transformation out to the edge changes the economics of media delivery. Instead of pre-generating every format and every width at build time and shipping the whole matrix to your origin, you store one high-quality master and let the edge derive AVIF, WebP, and the exact pixel dimensions each viewport needs — cached the first time and served from memory forever after. Done well, this collapses origin egress, shortens the critical path to the Largest Contentful Paint image, and lets a single URL serve the right bytes to a Safari 14 phone and a Chrome 121 desktop at the same time. Done badly, it poisons caches, doubles origin fetches, and serves an undecodable AVIF to the exact clients least able to recover. This section covers the theory and the platform-specific mechanics that separate the two outcomes.

The hard problems at the edge are not “how do I resize a JPEG.” They are: how does a shared cache decide which stored variant answers a given request, how do you keep that decision from fragmenting your hit rate into uselessness, and how do you insulate your origin from the transformation load when a cold cache stampedes it. Every platform below solves those three problems differently, and the differences are exactly where production incidents come from.


What this section covers

The topics below build from the general negotiation model down to per-platform configuration and the monitoring that keeps a working setup from silently regressing. Each area has its own dedicated pages.

Cloudflare Image Resizing and Polish — the /cdn-cgi/image/ transformation URL, Workers-based cf.image resizing, and Polish’s zero-config automatic WebP/AVIF re-encoding. When each applies, how they bill, and how to debug what actually reached the browser.

AWS CloudFront Cache Behaviors for Media — cache policies that include Accept in the cache key, CloudFront Functions versus Lambda@Edge for format routing, and diagnosing the cache misses that quietly send every request to your origin.

Fastly VCL for Image Format Negotiation — normalizing req.http.Accept in vcl_recv to a small set of cache variants, driving the Fastly Image Optimizer, and using shielding to concentrate transformation work on a single POP.

Monitoring & Regression for Media Delivery — enforcing image-weight budgets in Lighthouse CI, diffing WebPageTest filmstrips for LCP regressions, and tracking p75 LCP field data through the CrUX API so a bad deploy shows up before your users report it.


Edge delivery overview

The diagram traces one image request from the master asset on your origin through the edge transformation and cache layer to the browser. The cache-key box is the pivot point: everything upstream produces bytes, everything downstream consumes them, and the key decides whether a request is answered from edge memory or forwarded to origin.

Edge media delivery flow A left-to-right flow: Origin master image feeds an Edge Transform stage (decode, resize, re-encode to AVIF or WebP based on Accept). The transform output is stored under a Cache Key derived from the URL plus normalized Accept. On a hit the key returns cached bytes; on a miss it re-runs the transform. The final stage delivers to the Browser with Content-Type and Vary headers. Origin master one high-quality JPEG / PNG source shielded POP fetch Edge transform decode master resize to width/dpr read Accept header re-encode AVIF/WebP set Content-Type store Cache key URL + options + normalized Accept → avif / webp / jpeg HIT: serve memory MISS: re-transform miss re-runs transform serve Browser decodes format Vary: Accept paints LCP ↑ cold cache stampede hits origin ↑ raw Accept in key = cache shattered into hundreds of variants ↑ missing Vary = wrong format cached downstream

Core theory: transforming and negotiating at the edge

Edge image transformation

Edge transformation moves the decode-resize-re-encode pipeline from your build step (or origin server) into the CDN’s compute layer, executed the first time a given variant is requested. A request for a 640-pixel-wide AVIF derived from a 4000-pixel master causes the edge to fetch the master once, run it through a resize and an AVIF encoder, cache the result, and return it. Every later request for that same variant is a pure cache read.

This inverts the classic build-time model. With build-time generation you decide up front which widths and formats exist; anything you did not pre-generate 404s or serves an oversized fallback. With edge transformation the set of variants is open-ended — any width in the option string is valid — but you pay a one-time encode cost per unique variant and you must bound that set deliberately, because an unbounded set (arbitrary width values from client-side JavaScript, for example) turns every request into a cache miss and a fresh encode.

Tradeoff: on-the-fly AVIF is CPU-expensive. A single 1600-pixel AVIF encode is tens to low hundreds of milliseconds of edge compute. That cost is invisible on a warm cache and brutal on a cold one. The mitigation is a constrained, enumerable variant set plus origin shielding, both covered below.

What a transform actually costs

The encode is not the only cost, and knowing the shape of each stage tells you where to spend effort. On a single edge core, a 4000 × 3000 JPEG master decodes in roughly 40–70 ms; a Lanczos downscale to 1600 px adds another 10–20 ms; and the re-encode dominates everything else — on the order of 120–260 ms for AVIF at encoder speed 6, 25–45 ms for WebP at method 4, and 8–15 ms for a baseline MozJPEG. Raising the AVIF speed setting to 8 roughly halves the encode but gives up several percent of file size at matched quality, which is the compression/CPU curve catalogued in AVIF vs WebP compression benchmarks.

The whole of that cost lands on exactly one request per variant: the unlucky first viewer, whose time-to-first-byte absorbs decode plus resize plus encode before a single byte of image is sent. Three mitigations belong in the design from the start:

  • Request coalescing. If a hundred viewers hit the same cold variant within the encode window, a naive edge runs a hundred encodes. Cloudflare, Fastly, and a correctly configured CloudFront Origin Shield collapse concurrent misses for the same key into one upstream request; the rest wait on that single in-flight fetch. Verify this is actually on — it is the difference between one encode and a self-inflicted denial of service on a viral asset.
  • Serve stale while you re-encode. Emitting stale-while-revalidate on transformed responses lets the edge answer instantly from the previous variant while the refresh runs in the background, so a TTL expiry never becomes a latency cliff. The semantics and the exact directive combinations are covered in stale-while-revalidate for media assets.
  • Pre-warm what matters. For the handful of URLs that are LCP candidates on high-traffic templates, fire a build-time or post-deploy curl loop over each (width, format) pair against a shielded POP. Twenty requests at deploy time removes the cold-encode penalty from the one image the Largest Contentful Paint depends on.

A fourth option — do not transform at the edge at all for the small, stable, above-the-fold set — is legitimate and underused. Pre-generate the hero variants at build time, ship them as content-hashed objects, and let the edge transform only the rarely-requested tail of gallery and article imagery where pre-generation would be wasteful.

Accept-based negotiation at the edge

Browsers advertise decodable image formats in the Accept request header. Chrome 121 sends image/avif,image/webp,image/apng,*/*;q=0.8; Safari 14 sends image/webp,image/*,*/* (no AVIF); an ancient client sends only */*. Edge negotiation reads this header and picks the best format the client can actually decode — AVIF if present, else WebP, else the JPEG master — from a single canonical URL. The browser never has to author a <picture> fallback chain for format, because the edge already served the right bytes.

This is strictly more robust than static <picture> negotiation for format selection, because the decision is made against the live Accept header rather than a hard-coded type gate. It does not replace <picture>/srcset for resolution selection — the browser still chooses which width to request based on sizes and the display — but it removes the need to physically store an AVIF and a WebP copy of every asset. The format axis collapses into one URL. This is the same negotiation contract described for origin servers in Cache-Control Headers for Image and Video Assets, pushed one hop closer to the user.

Reading Accept without getting it wrong

Accept is a comma-separated list of media ranges with optional quality weights, defined in RFC 9110 §12.5.1. The obvious implementation — parse the ranges, sort by q, take the highest-weighted range the transformer can produce — is wrong, and it fails in the one direction that hurts: it serves AVIF to browsers that cannot decode it.

The reason is wildcards. Safari 14 sends image/webp,image/*,*/*;q=0.8. The image/* range has an implicit weight of 1.0 and matches image/avif under the media-range matching rules, so a q-ranking parser concludes the client happily accepts AVIF. It does not: a wildcard range advertises willingness to receive a media type, never the ability to decode a specific codec. The same trap catches */* on its own, which every curl invocation and most crawlers send.

The correct rule is a strict, ordered test for explicit subtype tokens, with no wildcard participation at all:

  1. If the header literally contains image/avif, serve AVIF.
  2. Otherwise, if it literally contains image/webp, serve WebP.
  3. Otherwise serve the JPEG or PNG baseline — including when the header is */*, image/*, empty, or absent entirely.

Two further edge cases bite in production. First, an explicit zero weight is a refusal: image/avif;q=0 means “do not send me AVIF”, and a substring test for image/avif will happily ignore that. Chrome does not currently emit it, but a corporate proxy or a hardened client can, so the strict token test should reject a token whose parameters include q=0. Second, an intermediate proxy that rewrites or strips Accept collapses every client into the baseline tier; if you see 100 % JPEG traffic from one ASN, suspect header stripping before you suspect the transformer.

Client hints are the modern alternative axis — Sec-CH-DPR and Sec-CH-Width let the edge pick a resolution without the URL carrying one — but there is no client hint for format support, so Accept remains the only signal for the format decision. Treat the two as orthogonal: Accept selects the codec, srcset/sizes or client hints select the pixel count, as laid out in mastering srcset and sizes for responsive layouts.

Cache-key design

A cache is a map from key to bytes. For negotiated media the key must encode everything that changes the response: the base URL, the transformation options (width, quality, fit), and enough of the Accept header to distinguish AVIF-capable clients from WebP-only ones — but no more than that. The design failure that fragments hit rates is keying on the raw Accept string. Chrome, Firefox, Safari, and every crawler send subtly different Accept values (different q weights, different */* orderings), so a raw-Accept key produces a distinct cache entry per user-agent family for the exact same image. Hit rate collapses; origin load and encode cost explode.

The fix is normalization: collapse the infinite space of real Accept strings into a tiny enumerated set — avif, webp, or jpeg — and key on that token instead. Fastly does this with a req.http.Accept rewrite in vcl_recv; Cloudflare does it internally for Polish and Image Resizing; CloudFront does it with a cache policy that either includes Accept (and relies on you normalizing upstream) or, more robustly, with a function that maps Accept to a small custom header used in the key.

Variant cardinality and the hit-rate arithmetic

Cache-key design is not a matter of taste; it is arithmetic you can do before you deploy. The number of distinct objects a single master image occupies at a single POP is the product of every dimension in the key:

objects = |formats| × |widths| × |quality values| × |everything else you accidentally keyed on|

Every one of those objects needs its own cold miss to become warm, and every cold miss for a transforming edge is a fresh encode. A POP holds a finite working set with an LRU eviction policy, so multiplying the object count does not merely delay warmth — past a threshold it guarantees that objects are evicted before they are requested a second time, and the hit rate stops recovering at all.

Work a concrete model. Take one master image, one POP that receives 2,400 requests a day for it, and a 24-hour TTL. Requests are spread across the object set roughly uniformly, so the expected number of misses over the day is N × (1 − e^(−R/N)) for N objects and R requests. In production, browsers emit around 120 distinct raw Accept strings across versions and platforms, and unclamped client-side width values settle at roughly 15 distinct values once device pixel ratios and layout breakpoints multiply out. That gives four candidate designs:

Keying strategy Formats × widths Objects per master Modelled daily misses Hit rate
Raw Accept, unclamped width 120 × 15 1,800 ~1,325 45 %
Raw Accept, 5 allowlisted widths 120 × 5 600 ~589 75 %
Normalized token, unclamped width 3 × 15 45 45 98 %
Normalized token, 5 allowlisted widths 3 × 5 15 15 99 %
Cache-key cardinality versus edge hit rate A horizontal bar chart of four cache-key strategies. Keying on the raw Accept header with unclamped widths creates 1,800 objects per master and reaches only a 45 percent hit rate. Raw Accept with five allowlisted widths gives 600 objects and 75 percent. A normalized format token with unclamped widths gives 45 objects and 98 percent. A normalized token with five allowlisted widths gives 15 objects and 99 percent. Cache-key cardinality versus edge hit rate modelled: one master image, one POP, 2,400 requests/day, 24 h TTL Raw Accept + unclamped width 1,800 objects per master 45 % Raw Accept + 5 allowlisted widths 600 objects per master 75 % Normalized token + unclamped width 45 objects per master 98 % Normalized token + 5 widths 15 objects per master 99 % 0 % 25 % 50 % 75 % 100 % Normalizing the format axis buys more than clamping the width axis — but only doing both keeps the object set enumerable.

Two lessons fall out of the table. The first is that normalizing Accept is worth more than clamping widths: collapsing 120 strings to 3 tokens moves the hit rate 23 points further than trimming 15 widths to 5. The second is that the two fixes are not alternatives. Leaving either axis unbounded leaves you one careless change — a new ?dpr= parameter, a marketing ?utm_source that slips into the key — from multiplying the object count again. Enumerate both axes explicitly and reject anything outside the enumeration with a 400 rather than silently encoding it.

Warning: the arithmetic above is per POP. A global CDN with 42 POPs multiplies the cold-miss count by 42 unless a shielding tier absorbs them, which is why the shielding discussion below is not an optional optimization but part of the same calculation.

Vary: Accept versus a normalized cache key

There are two correct ways to make a shared cache serve the right format, and mixing them up is a classic incident:

  • Vary: Accept tells every downstream RFC-9111 cache (the CDN, corporate proxies, the browser cache) to store a separate object per distinct Accept value. It is honest and standards-compliant, but because Accept values vary wildly between clients, a literal Vary: Accept can itself shatter the cache unless the CDN normalizes Accept before applying it.
  • A normalized cache key keeps the key internal to the CDN: the edge computes avif|webp|jpeg from Accept, keys on that, and typically emits a stable representation to downstream caches. This is what Cloudflare does automatically and what a well-written Fastly VCL does explicitly.

Warning: emitting Vary: Accept while also letting a downstream CDN key on the raw header double-fragments the cache — once for the header value and once for the internal token. Pick one model per hop. The rule of thumb: normalize Accept to a token as early as possible, key on the token, and only emit Vary: Accept to caches you do not control (the browser and intermediary proxies) so they at least do not serve an AVIF to a WebP-only client.

Three consequences of Vary are easy to miss until they cause an incident:

  • Revalidation carries the Vary set too. When a stored variant goes stale, an RFC-9111 cache issues a conditional request and must present the same selecting header values it originally stored. If a client’s Accept has drifted between the original request and the revalidation — a browser update is enough — the cache cannot match the stored variant and falls back to a full fetch. Long immutable lifetimes on content-hashed URLs sidestep this entirely, because nothing ever revalidates.
  • Vary: * is a caching veto. Some frameworks emit it defensively. It means “this response is uncacheable by any shared cache”, and applied to media it takes your entire image tier off the edge. Grep for it before blaming the CDN.
  • Vary does not protect against a wrong Content-Type. It only tells a cache which requests may reuse a stored response. If the transformer labelled an AVIF payload image/jpeg, Vary will faithfully serve that mislabelled object to everyone in the same variant bucket. Content-type correctness is a separate contract, covered in MIME type configuration for modern media servers.

The practical policy for a negotiated media tier is therefore: normalize at the outermost hop you control, key internally on the token, emit Vary: Accept on the response so the browser cache and any corporate proxy stay honest, and give every transformed object a Cache-Control lifetime long enough that revalidation is rare.

Shielding and origin offload

A CDN has dozens or hundreds of edge POPs, each with its own cache. On a cold deploy, a popular image can miss simultaneously at every POP, and every POP independently fetches the master from your origin — a thundering herd that can saturate origin bandwidth exactly when traffic is highest. Shielding (Fastly’s term; Cloudflare calls the analogous mechanic Tiered Cache / Argo, CloudFront calls it Origin Shield) designates one intermediate POP that all other POPs consult before reaching origin. Origin then sees at most one fetch per variant instead of one per POP. For edge transformation this matters doubly: the shield POP also concentrates the expensive encode, so a variant is transformed once globally rather than once per region.

Shielded fan-in for edge transformation Six edge POPs on the left send concurrent cache misses into a single shield POP in the centre. The shield coalesces those misses into one upstream request, runs the AVIF encode once, and caches the result. A single arrow continues to the origin master on the right, which therefore sees one request per variant instead of one per POP. POP · LHR POP · CDG POP · NRT POP · GRU POP · IAD + 37 more POPs 42 concurrent misses Shield POP coalesces concurrent misses into one fetch runs the AVIF encode exactly once 1 fetch Origin master 1 request per variant encode cost paid once globally Unshielded: 42 origin fetches and 42 AVIF encodes per new variant Shielded: 1 origin fetch and 1 AVIF encode per new variant Cloudflare Tiered Cache · Fastly shielding · CloudFront Origin Shield

Choosing the shield location is a latency decision, not a throughput one. Put the shield in the region closest to origin: a miss now costs the viewer one extra edge-to-shield hop, and you want that hop to be the short one. Placing a European shield in front of a us-east-1 origin adds a transatlantic round trip to every miss served in Asia. Most platforms expose the shield as a per-origin setting, so a distribution with a US object store and a European API can shield each independently.

Tradeoff: shielding converts a wide, shallow failure into a narrow, deep one. When the shield POP has a bad minute, every POP behind it is affected simultaneously, whereas an unshielded fleet degrades one region at a time. Platforms mitigate this with automatic failover to a secondary shield or a direct-to-origin path, but the operational consequence is real: shield health belongs on the same dashboard as origin health, and the alert threshold should be tighter than for any single edge POP.

There is a second, quieter benefit worth designing around. Because the shield holds a superset of what any single POP holds, its hit rate for the rarely-requested tail of rarely-requested gallery images is far higher than the edge’s. For a catalogue where most assets are requested a handful of times per day globally, the shield is where the cache actually lives; the edge tier only serves the head of the distribution. That changes what you measure — a mediocre edge hit rate with an excellent shield hit rate is a healthy system, not a broken one, and monitoring that reports only the edge number will mislead you.


Platform capability reference

The table compares how the three major CDNs handle the four capabilities that decide a media architecture. “Edge compute” is the mechanism you would use for custom routing logic beyond the built-in transformer.

Capability Cloudflare Fastly AWS CloudFront
On-the-fly AVIF/WebP Yes — Image Resizing (/cdn-cgi/image/) and Polish auto re-encode Yes — Image Optimizer (?format=… / auto) No native transformer; build with Lambda@Edge + Sharp
Automatic format from Accept Yes — Polish webp/avif, format=auto in Image Resizing Yes — format=auto with normalized Accept in VCL Manual — function maps Accept, cache policy keys on it
Vary: Accept handling Respected automatically; Polish keys internally Honors Vary; typically normalize Accept in vcl_recv first Must add Accept to the cache policy or it is stripped
Edge compute for custom logic Workers (fetch with cf.image options) VCL, plus Compute (Wasm) CloudFront Functions (lightweight) / Lambda@Edge (heavy)
Purge granularity Single-file, tag, or wildcard purge Instant URL and surrogate-key purge (~150 ms) Path-based invalidation (slower, quota-limited)
Where transform runs Edge, cached per variant Edge / shield POP Lambda@Edge region, cached per variant
Signed transformation URLs Yes — signed Image Resizing URLs Yes — token validation in VCL Yes — signed URLs / cookies, or HMAC in the function
Request coalescing on a cold key Yes (with Tiered Cache) Yes (clustering + shielding) Yes (with Origin Shield enabled)
Billing unit for a transform Per unique transformation Per image-optimizer request Lambda@Edge GB-seconds + requests

Tradeoff: Cloudflare and Fastly ship a managed transformer, so you write configuration; CloudFront makes you assemble the transformer from Lambda@Edge, so you write and maintain code. The CloudFront path is more work but gives byte-level control over the encoder and its parameters — see Lambda@Edge AVIF conversion on CloudFront.


Canonical delivery pattern

The most portable canonical pattern is a transformation URL that carries the derivation intent in the path and lets the edge negotiate format from Accept. Cloudflare’s form is the clearest example:

<!--
  The /cdn-cgi/image/ prefix is intercepted by Cloudflare's edge before it
  ever reaches your origin. The options segment declares the derivation:
    width=640     target width in device pixels (bounds the variant set)
    quality=75    perceptual quality; 75 is a strong photographic default
    format=auto   negotiate AVIF/WebP/JPEG from the request Accept header
  The final segment is the ORIGIN path of the master image. One tag, and
  the edge serves AVIF to Chrome and WebP to Safari 14 from this single URL.
-->
<img
  src="/cdn-cgi/image/width=640,quality=75,format=auto/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"
  sizes="(max-width: 700px) 100vw, 640px"
  width="640" height="400"
  alt="Product hero photographed on a neutral background"
  fetchpriority="high">

For CloudFront the canonical unit is not a URL prefix but a cache policy that admits Accept into the key so the negotiated variant is stored correctly:

// CloudFront cache policy (Terraform-style shape). Without Accept in the
// cache key, CloudFront strips it and caches whichever format was produced
// first — then serves that one format to every client. This is the single
// most common cause of "AVIF shows up in Safari 14" incidents on CloudFront.
{
  "Name": "media-accept-negotiation",
  "ParametersInCacheKeyAndForwardedToOrigin": {
    "HeadersConfig": {
      "HeaderBehavior": "whitelist",
      "Headers": { "Items": ["Accept"] } // normalize upstream to avoid fragmentation
    },
    "QueryStringsConfig": { "QueryStringBehavior": "whitelist",
      "QueryStrings": { "Items": ["width", "quality"] } },
    "CookiesConfig": { "CookieBehavior": "none" }
  }
}

Full walk-throughs live in CloudFront cache policy for Vary/Accept negotiation and, for the URL-parameter form, Configuring Cloudflare Image Resizing URL parameters.


Pipeline integration

Fastly: normalize then negotiate

Fastly runs your logic in VCL. The pattern is to collapse Accept to a token in vcl_recv so the cache keys on three values, not thousands:

sub vcl_recv {
  # Normalize the wildly-varying Accept header down to a single token.
  # This token — not the raw header — becomes part of the cache variant,
  # so Chrome, Firefox, and crawlers that all support AVIF share ONE entry.
  if (req.http.Accept ~ "image/avif") {
    set req.http.X-Format = "avif";
  } elsif (req.http.Accept ~ "image/webp") {
    set req.http.X-Format = "webp";
  } else {
    set req.http.X-Format = "jpeg";
  }
  # Shielding: funnel all POPs through one datacenter so origin and the
  # image transform each run once per variant globally, not once per POP.
  if (req.http.host && !req.http.Fastly-FF) {
    set req.backend = ssl_shield_bwi_va_us;
  }
}

The X-Format token then drives the Image Optimizer format and is included in the object variant. Details: Fastly VCL: normalize Accept header for AVIF.

Cloudflare Workers: programmatic resizing

When the built-in URL form is not expressive enough — conditional quality, signed URLs, per-route rules — a Worker calls fetch with cf.image options:

export default {
  async fetch(request) {
    const accept = request.headers.get('Accept') || '';
    // Choose the encoder format from the live Accept header. format:'auto'
    // also works; picking explicitly lets you gate AVIF behind a flag.
    const format = accept.includes('image/avif') ? 'avif'
                 : accept.includes('image/webp') ? 'webp'
                 : 'jpeg';
    const url = new URL(request.url);
    return fetch('https://origin.example.com' + url.pathname, {
      cf: {
        image: {
          width: 640,        // bound the variant set — never pass raw client input unclamped
          quality: 75,       // perceptual quality target
          format,            // negotiated above
          fit: 'scale-down', // never upscale past the master's intrinsic size
        },
        // Cache the transformed result at the edge for a year; the master is
        // content-hashed so a new deploy produces a new URL rather than a stale hit.
        cacheEverything: true,
        cacheTtl: 31536000,
      },
    });
  },
};

The full Workers and Polish setup is in Cloudflare Image Resizing and Polish.

Monitoring the pipeline

An edge media setup degrades silently: a cache policy change drops the hit rate, a quality bump inflates payloads, a format regression ships AVIF to WebP-only clients. Wire the pipeline to a budget so regressions fail a build rather than a user’s LCP — enforce image-weight assertions in Lighthouse CI budget enforcement for image weight and watch the p75 field trend in tracking LCP field data with the CrUX API.


Invalidating derived variants

Purging a transformed asset is harder than purging a static one, because the thing you want to remove is not the thing you uploaded. When a designer replaces hero-master.jpg, the stale bytes at the edge are not stored under /img/hero-master.jpg — they are stored under /cdn-cgi/image/width=640,quality=75,format=auto/img/hero-master.jpg and a dozen sibling option strings, each with its own negotiated format variant. Purging the master path clears the origin copy and leaves every derivative untouched.

There are three workable strategies, in increasing order of robustness:

  1. Enumerate and purge the derivatives. Feasible only when the option set is small and known to your build. It is the approach that breaks first, because it silently misses any URL an engineer added by hand.
  2. Purge by surrogate key or cache tag. Tag every derivative of a master with the master’s identifier at transform time (Surrogate-Key: img-hero-master on Fastly, Cache-Tag: on Cloudflare Enterprise), then purge the tag. One call removes all widths and all formats. This is the correct answer when your platform supports it and you can control response headers on the transformed object.
  3. Never purge at all: content-hash the master path. If the master is hero.a3f9c2.jpg, then every derivative URL contains that hash, and replacing the image produces hero.b71e04.jpg — a completely disjoint key space. The old derivatives age out on their own TTL with no purge call, no propagation delay, and no quota. Paired with Cache-Control: max-age=31536000, immutable this is the production standard, and it is why best practices for setting max-age on CDN media assets treats hashing as a prerequisite for long lifetimes rather than an optimization.

Warning: a global “purge everything” during an incident throws away every warm derivative across the whole fleet. On a transforming edge that is not just a cache-fill event — it re-runs every encode, so a purge-all can cost more CPU in ten minutes than a normal week. Reach for tag-scoped purges, and treat purge-all as a break-glass action with the same review as a config rollback.

Locking down the transformation endpoint

An open transformation URL is an open image proxy. If /cdn-cgi/image/width=2000,quality=95/<any path> accepts an arbitrary origin path — or worse, an arbitrary remote URL — anyone can point it at their own assets, mint an unbounded number of cache-key variants, and bill the CPU to you. Two controls close it:

  • Restrict the source. Only allow derivation from paths under your own asset prefix, and reject absolute URLs outright. Cloudflare Image Resizing restricts this to the zone by default; a hand-rolled Worker or Lambda@Edge does not, unless you write the check.
  • Sign the option string. Append an HMAC of the path plus options, computed with a server-side secret, and have the edge reject any request whose signature does not match. Signing has the useful side effect of making the variant set literally unforgeable: only option strings your application generated will ever produce an encode.

Signing costs you the ability to hand-write a transformation URL in a template, so most teams generate them through a single helper function. That helper is a good place to also enforce the width allowlist, clamp quality to a sane band, and emit the srcset string, so the enumeration lives in exactly one file.


Tradeoffs & failure modes

Failure mode Cause Fix
Hit rate collapses after enabling negotiation Cache keyed on raw Accept header, one entry per user-agent family Normalize Accept to avif/webp/jpeg before it enters the key
Safari 14 shows a broken image Edge served AVIF; Accept not consulted or wrong format cached Read Accept at the edge; on CloudFront add Accept to the cache policy
Origin bandwidth spikes on every deploy Cold caches at every POP fetch the master simultaneously Enable shielding / Origin Shield / Tiered Cache to collapse origin fetches
Edge CPU / bill spikes Unbounded width values create a new encode per request Clamp width to an allowlist of breakpoints; reject arbitrary values
Transformed image ignores a new master Long immutable TTL on a non-hashed URL Content-hash the master path so a new asset is a new cache key
format=auto still serves JPEG to Chrome Accept stripped by an intermediate proxy or not forwarded to the transformer Confirm Accept reaches the edge; forward it in the cache/origin request policy
Double cache fragmentation Emitting Vary: Accept while the CDN also keys on raw Accept Normalize once; key on the token; reserve Vary for caches you do not control
Purge does not clear a bad variant Purged the master path but not the derived transformation URLs Purge by surrogate/cache tag, or purge the /cdn-cgi/image/… derivations too
Safari 14 gets AVIF despite “correct” parsing Parser ranked image/* by q weight instead of testing explicit tokens Match only literal image/avif / image/webp subtypes; ignore all wildcards
Edge tier looks unhealthy but users are fine Tail-heavy catalogue: the real cache lives on the shield, not the edge Report shield hit rate alongside edge hit rate before tuning anything
CPU bill triples after an incident A purge-all re-ran every encode across the fleet Scope purges to a surrogate key; treat purge-all as a break-glass action
Someone else’s images served from your CDN Open transformation endpoint accepts arbitrary source paths Restrict derivation to your own prefix and HMAC-sign the option string
First view is slow, later views are fast Cold-variant encode landing in the viewer’s TTFB Pre-warm LCP variants at deploy and enable stale-while-revalidate

Browser & CDN compatibility matrix

Format decode support is a browser property; the edge only decides which of these you send. The columns match the site-wide baseline.

Feature Safari 14 Safari 16 Chrome 85+ Firefox 93+ Edge 18+
Accepts image/webp Yes Yes Yes Yes Yes
Accepts image/avif No Yes (16.0+) Yes Yes Yes (18+)
Sends AVIF in Accept No Yes Yes Yes Yes
Honors Vary: Accept in browser cache Yes Yes Yes Yes Yes
<img srcset> width negotiation Yes Yes Yes Yes Yes
fetchpriority on <img> Yes (15.4+) Yes Yes (102+) Yes (132+) Yes (102+)

Edge capability by platform

Capability Cloudflare Fastly AWS CloudFront
Managed image transformer Yes (Image Resizing) Yes (Image Optimizer) No (build on Lambda@Edge)
Zero-config auto re-encode Yes (Polish) No No
Reads Accept without extra config Yes Needs VCL normalize Needs cache policy
Sub-second global purge Yes Yes No (invalidation lag)
Origin offload mechanism Tiered Cache / Argo Shielding Origin Shield

Frequently asked questions

Should the edge transform images, or should the build?

Both, on different halves of the catalogue. Pre-generate the small, stable, high-traffic set — hero images, category thumbnails, anything on a template that renders millions of times — because you know the variants in advance and a build machine’s CPU is cheaper than an edge worker’s. Let the edge handle the rarely-requested tail, where pre-generating every width × format for every user-uploaded asset would produce a matrix you never fully serve. The split is a cost decision, and the crossover is roughly where an asset’s lifetime request count stops justifying a build-time encode.

Does edge negotiation replace the <picture> element?

For format, yes; for art direction and resolution, no. Once the edge picks the codec from Accept, a <source type="image/avif"> chain is redundant markup that only adds a way to get out of sync. What <picture> still does uniquely is swap a different crop or composition at a breakpoint, which no header can express, and srcset/sizes still choose the pixel count. The division of labour is set out in art direction with the HTML picture element.

What hit rate should a healthy media tier show?

For a head-heavy catalogue with a normalized key and clamped widths, 95 % and above at the edge is normal and anything below 90 % is worth investigating. For a tail-heavy catalogue the edge number is structurally lower — 70–85 % is common — and the shield hit rate is the one that matters. The metric to alert on is not the absolute number but its derivative: a five-point drop within a deploy window almost always means a new dimension entered the cache key.

Why did enabling AVIF make my LCP worse?

Two usual causes. The first is cold-encode latency landing on the LCP request itself: the AVIF is smaller, but the viewer waited 200 ms for it to exist. The second is that AVIF decode is more expensive than JPEG decode on low-end devices, and on a phone with a weak CPU the decode can outweigh the transfer saving on a fast connection. Measure both by comparing Content-Download against the paint timestamp rather than trusting file size alone, and confirm the direction of travel in the field with CrUX field data tracking.

Can I key on User-Agent instead of Accept?

No. User-Agent has vastly higher cardinality than even the raw Accept header, so keying on it fragments the cache catastrophically, and it is a less accurate signal: a browser’s advertised support is authoritative about what it will decode, while a version string requires you to maintain a mapping table forever. If you need coarse device information for a resolution decision, use client hints, which are designed for the job and are explicitly cache-friendly.

How do I roll a format change back safely?

Ship the format decision behind a flag evaluated at the edge, not behind a rebuild. Because the format is chosen per-request from Accept, disabling AVIF is a one-line change in the transform options that takes effect on the next miss — but existing warm AVIF objects keep serving until they expire. If you need an instant rollback, you must also purge the AVIF variants, which is another argument for tagging every derivative with its format at transform time so purge tag=fmt-avif is possible.


Where to go next

Start with the platform you run. If you are on Cloudflare, the Image Resizing and Polish guide is the fastest path to a working negotiated pipeline. On AWS, begin with CloudFront cache behaviors for media and get the cache policy right before adding transformation. On Fastly, the VCL negotiation guide covers the normalize-then-transform pattern. Regardless of platform, wire up monitoring and regression detection before you trust the setup in production — an unmonitored edge pipeline is one deploy away from a silent LCP regression.