
Your image is 40KB and perfect. The visitor still redownloads it on every page because Cache-Control is wrong. Here's the fix, with real bandwidth numbers.
They leave while the second page is still painting. Not because the image is heavy. It's 42KB, you compressed it right. They leave because your CDN handed them a Cache-Control: no-cache header and the browser refetched all 42KB when it already had it. Fix the header and that byte gets served once, then read from disk for a year.
I test this on real funnels, and I'm testing the idea on you right now: the drop-off between page one and page two of a session is where cache strategy quietly pays or bleeds. A perfectly optimized image with a bad cache header is a leak you can't see in your file sizes. It only shows up in repeat bandwidth and in the extra 200ms that pushes a mobile visitor toward the back button.
CDN cache headers decide how many times a browser and a CDN redownload an image you already optimized. Compression sets the size of one download. Cache headers set how many downloads happen. You can win the first fight and lose the war.
Here's the mechanism. When a browser requests hero.avif, the response carries a Cache-Control header. That header tells the browser (and every CDN edge node in between) whether it can store the file, for how long, and whether it must revalidate before reusing it. Get it right and the second, third, and hundredth page view read the image from local disk in about 0ms of network time. Get it wrong and you re-pay the transfer every single navigation.
A 42KB image served with
no-cacheacross a 6-page session costs 252KB of transfer. The same image with a one-yearmax-agecosts 42KB. That's a 6x difference on one asset, and most sites have 20 to 40 images per page.
So the real image-optimization stack is two layers. Shrink the bytes. Then make sure each visitor pays for those bytes once (see Shrink the bytes).
Static, versioned images should use Cache-Control: public, max-age=31536000, immutable. That's one year, cacheable by shared CDNs, and immutable tells the browser to never even send a revalidation request.
The values, plainly:
public means CDN edge nodes and shared caches are allowed to store it, not just the end user's browser. This is what lets your CDN serve the file without hitting your origin.max-age=31536000 is the freshness window in seconds. 31536000 is one year, the practical maximum recommended by the HTTP spec.immutable tells supporting browsers to skip the conditional revalidation (If-None-Match) request entirely. Without it, a browser on reload still fires a request and waits for a 304 Not Modified. The 304 is small, but the round trip on mobile is 100 to 300ms of nothing.The MDN Cache-Control reference spells out each directive if you want the full list. The one that trips people up is no-cache. It does not mean "do not store." It means "store it, but revalidate with the server before every reuse." That's why an image with no-cache feels cached but still generates a request on every page. no-store is the one that means don't keep it at all.
You use a fingerprinted filename, so a changed image gets a new URL. The old URL keeps its year-long cache safely because it never changes, and the new file is served fresh because its name is different.
This is the part people fear. "If I cache for a year and then need to swap the hero, everyone's stuck with the old one." True, if the filename stays the same. The answer is content hashing: hero.a3f8c1.avif instead of hero.avif. When the image changes, the build produces hero.9b2d44.avif, the HTML points at the new name, and browsers treat it as a brand-new resource. The old hash still sitting in someone's cache is harmless.
Most build tools do this automatically. Next.js, Vite, and Webpack all emit hashed asset names by default. If you're serving images straight from a folder or a CMS, add a version query string (?v=2) or a hash to the path yourself. The rule: the URL is a promise. If the URL is stable, the bytes must be stable, and you can cache forever.
Not every image gets a year. Here's how I set it, mapped to how often the file actually changes:
| Image type | Filename | Cache-Control | Why |
|---|---|---|---|
| Versioned static assets (logos, hero, icons) | hashed / fingerprinted | public, max-age=31536000, immutable |
URL changes when bytes change, so cache forever |
| CMS uploads with stable URLs | stable path | public, max-age=86400 (1 day) |
Editors may replace in place, shorter window is safer |
| User avatars / profile images | stable path | public, max-age=3600 (1 hour) |
Change often, short TTL plus CDN purge on update |
| One-off dynamic renders (OG images) | query params | public, max-age=604800 (1 week) |
Rarely re-requested, but cheap to hold |
The pattern: the more control you have over the URL changing when the content changes, the longer you can cache. Fingerprinted files get the maximum. Anything that mutates under a fixed URL gets a short TTL plus an active CDN purge when it updates.
A mid-size site serving 100,000 sessions a month with 30 images per page and a 4-page average session redownloads roughly 12 million image requests it didn't have to. At an average optimized image size of 40KB, that's around 480GB of repeated transfer a month that a correct max-age would have eliminated.
Run the math on your own numbers:
Google's web.dev guidance on HTTP caching puts it directly: setting a long max-age on immutable, versioned resources is one of the highest-leverage performance wins available, because the fastest request is the one that never leaves the device. On a repeat visit, a correctly cached page can drop from 1.5MB of transfer to near zero for its static assets. That's the difference between a second page that paints instantly and one that stalls on a spinner.
And the conversion tie-in is not hand-waving. Repeat visitors are your warmest traffic, the people deepest in the funnel. Making them re-download your gallery on every click is taxing the exact segment most likely to convert.
Pixel Wand handles the byte size so the cached file is as small as it can be without visible loss. Then your CDN headers handle how long that small file survives. The two are meant to stack.
The workflow I'd run: compress and convert your images with Pixel Wand so each file is SSIM-checked for quality and shipped as WebP or AVIF at the smallest size that still looks clean. Give the output a fingerprinted filename in your build. Set Cache-Control: public, max-age=31536000, immutable on that path at the CDN. Now the first visit downloads a 40KB AVIF instead of a 180KB PNG, and every visit after that downloads nothing (see WebP vs AVIF at equal SSIM: a 100-photo compression benchmark) (see WebP or AVIF).
Compression without caching means every visitor re-pays your good work. Caching without compression means you're efficiently serving bloated files forever. You want both: the smallest possible byte, served exactly once per visitor.
Not reliably. A CDN caches based on the origin's response headers. If your origin sends no-cache, private, or no Cache-Control at all, many CDNs either refuse to cache or fall back to a short default TTL. You have to send public with a real max-age for the CDN to hold the file confidently.
no-cache is Y where the browser stores the file but revalidates with the server before every reuse, so you still get a request each time. no-store means the file is never written to cache at all. Neither is what you want for optimized images. Use public, max-age with a long window instead.
Only if the URL stays the same when the bytes change. Use content-hashed or versioned filenames so an updated image ships under a new URL. The browser treats it as new and fetches it fresh, while the old cached copy sits harmlessly unused.
Next step: open your network tab, reload a page, and look at the Cache-Control column on your image requests. Any image showing no-cache, no-store, or a max-age under 86400 with a stable filename is a leak. Fix those headers first, then feed the images through compression so the one download each visitor pays for is as light as it goes.
Find any asset in seconds. Photo Atlas is digital asset management for creative and brand teams, with early-access founder pricing for the first users. Get early access
Optimize images with SSIM-based compression. 10 free conversions per day, no credit card required.