A transforming proxy turns one stored image into as many renditions as your URLs ask for. That is the feature. It is also, if you never bound it, the bill — and the arithmetic is worth doing on paper before it gets done for you in production.
The multiplication
Every parameter a URL may carry is a dimension of the cache key. Suppose you allow, quite reasonably:
- any width from 100 to 2000 pixels
- any quality from 1 to 100
- three output formats
- four fit modes
That is 1901 × 100 × 3 × 4 — roughly 2.3 million distinct
renditions. Of one image.
Nobody requests all of them, of course. But you do not need all of them for this to hurt. You need enough distinct values that your hit rate collapses, and that happens far earlier than the total suggests, because the requests are spread thinly across a huge space rather than concentrated on a few hot entries.
Where the odd values come from
Teams are often surprised that they have hundreds of widths in play. The usual sources:
- A JavaScript component computing a width from the container. Every viewport width produces a different number. Phones alone give you dozens.
- Device pixel ratio arithmetic.
width × 2on one device and× 3on another, from a container that was already a fractional size. - Someone testing in production. A handful of one-off values that stay in the cache and in the logs forever.
- Scrapers and scanners walking the parameter space deliberately.
What a miss actually costs
A cache hit is a lookup and some bytes. A miss is:
- a request to your origin for the source image, which you are billed for;
- a decode of that source;
- a resize;
- an encode — in AVIF, the expensive step;
- a write into the cache, evicting something else.
That last point is the one that compounds. A cache is finite. Every rendition nobody will ask for again pushes out one that somebody would have. So an unbounded parameter space does not merely add cost, it actively degrades the hit rate for the images you actually care about.
Bounding it
The fix is not clever, it is just deliberate: decide which values are allowed, and refuse the rest.
Pick widths from your layout
Your design has breakpoints. It does not have 1901 of them. Take the widths your layout actually renders at, double the ones that need to look right on high-density displays, and you will usually finish with somewhere between four and eight numbers.
allowed widths: 320, 640, 960, 1280, 1920
Fix quality, do not expose it
Quality is a decision your team makes once. There is very little reason for it to be a URL parameter that any caller can set — it multiplies the key space by up to a hundred in exchange for a knob nobody turns after launch.
Name the common combinations
A named preset collapses several dimensions into one token. ?preset=hero is one
cache entry per image, whatever the preset happens to expand to, and changing the preset later
is a single edit rather than a hunt through every template.
Refuse rather than adjust
When a request asks for a size you do not publish, there are two things a proxy can do: refuse it, or quietly serve the nearest allowed size instead.
Snapping is tempting — nothing visibly breaks. It is also a lie: the URL says
w=1100 and the bytes are 1200 pixels wide, and two URLs have silently become one
cache entry. Anyone debugging a layout later gets to discover that on their own.
Refusing is louder and, we think, correct. A 400 appears in your logs, names the problem, and gets fixed in the template where it originated. The URL keeps meaning what it says.
A worked example
Concretely, with five widths, one quality, negotiated format, one fit mode:
5 widths × 1 quality × 3 formats × 1 fit = 15 renditions per image
Fifteen, against 2.3 million. The images are identical; the difference is entirely in what the URLs were permitted to say. Fifteen fits in a cache, warms up in minutes, and costs your origin fifteen fetches rather than an open-ended number.
That is the whole argument for bounding a parameter space, and it is why the console has a place to write those numbers down.