Two caches, two sets of rules
A product image passes through at least two caches between your store and a shopper's screen, and they behave differently.
The edge cache lives on the CDN's servers. It is shared: once one shopper in Toronto has requested an image, the next thousand shoppers near that edge location get the stored copy without touching your origin. You control it, and you can clear it.
The browser cache lives on each shopper's device. It is private: it only helps on repeat views, such as moving from a collection page to a product page that reuses the same thumbnail, or coming back tomorrow. You can tell it how long to keep a file, but once it has a copy you cannot reach in and remove it.
That last point shapes the whole strategy. Any instruction you give a browser is a promise you cannot take back, so the rules have to be safe even when you later change your mind about an image.
| Edge cache (CDN) | Browser cache | |
|---|---|---|
| Who benefits | Every shopper near that location | One shopper, on repeat views |
| Can you clear it? | Yes, with a purge | No |
| Controlled by | CDN settings and response headers | Response headers only |
| Main risk | Low hit ratio, origin does the work | Stale image you cannot recall |
How long should a product image be cached?
The answer depends on one thing: does the URL change when the image changes?
If it does, cache for as long as the platform allows. A year is the conventional ceiling. The file at that address will never be different, so there is no such thing as a stale copy. The header for this looks like Cache-Control: public, max-age=31536000, immutable. The immutable part tells the browser not to bother checking with the server when the shopper reloads the page.
If the URL stays the same when the image changes, a long lifetime is a trap. A shorter lifetime is the only protection, and you pay for it with extra requests on every visit after it expires.
| Asset | URL changes on update? | Sensible lifetime |
|---|---|---|
| Product images with a version or hash in the URL | Yes | One year, immutable |
| Product images at a fixed URL | No | Hours to a day, with revalidation |
| Campaign banners overwritten in place | No | Minutes to hours |
| The HTML page that references the images | n/a | Short, or not cached in the browser |
The last row matters as much as the first. Long-lived images only work because the page that points at them is fresh. The page is where the new URL appears, so the page has to be the short-lived part.
You can give the two caches different lifetimes. s-maxage applies to shared caches like the CDN and max-age applies to browsers. A long edge lifetime with a shorter browser lifetime is a reasonable setup for images at fixed URLs, because the edge copy is the one you can purge.
Versioned URLs: the only reliable way to replace an image
There are two ways to get a new image in front of shoppers. One is to keep the URL and tell every cache to forget the old file. The other is to give the new image a new URL. Only the second one is reliable, because purging reaches the CDN and stops there. Browsers that already hold the old file keep showing it until their copy expires.
Versioning takes a few forms:
- A content hash in the filename, such as
boot-black-side.3f9a1c.jpg. The name is derived from the file, so a different image always gets a different name. - A version query parameter, such as
boot-black-side.jpg?v=1727900000. Simpler to add, but check that your CDN includes the query string in its cache key. If it ignores query strings, the old and new versions are the same object as far as the edge is concerned. - A new file altogether. Upload the replacement as a new image and point the product at it, rather than overwriting the old one.
Hosted platforms mostly handle this for you. Shopify serves product media from its own CDN and adds a version parameter to image URLs, so an updated image arrives at a new address. The same is true when a tool pushes a finished image to a product through the Shopify API: Retouchable's push adds the image as a new file on the product rather than overwriting bytes at an old URL, so there is nothing stale to chase. The case to watch is anything you host yourself, plus any image URL that has been copied by hand into a theme file, an email template or a marketplace feed.
Overwrite in place
- Same URL, new bytes
- Needs a CDN purge every time
- Returning shoppers may see the old image until expiry
- Forces short cache lifetimes
- No easy way back to the previous image
Versioned URL
- New URL for every change
- No purge needed
- Everyone gets the new image on the next page load
- Allows one-year, immutable caching
- Old version stays addressable for rollback
The rollback point is worth planning for. If the old file still exists at its old URL, undoing a bad update is a matter of pointing the product back at it. There is more on that in rolling back a bad product image update on Shopify.
What quietly splits your cache
A CDN can only reuse a stored image when a new request matches the stored one exactly. The rule for "matches" is the cache key, and a product catalog has several ways of producing many keys for what is really one image. Each extra key is a cache miss, and each miss on an image CDN usually means a fresh resize and re-encode at the origin.
Arbitrary widths. If your theme requests whatever width the layout computes, such as 613, 614 and 617 pixels, each one is a separate object. Pick a fixed ladder of widths and request only those. The srcset and sizes guide covers how to choose the steps.
Tracking parameters. Campaign parameters such as utm_source belong on page URLs. If they leak onto image URLs, every campaign creates its own copy of every image. Most CDNs let you list which query parameters count toward the cache key. Keep width, format, quality and version, and drop the rest.
Parameter order. ?width=800&v=3 and ?v=3&width=800 are different strings. Some CDNs normalise the order and some do not. Generating image URLs from one helper, rather than by hand in several templates, avoids the question.
Format negotiation. Serving AVIF or WebP to browsers that support them means the CDN keeps a variant per format, chosen from the request's Accept header. That is expected. The problem comes when the cache varies on the full header text, because browsers send many slightly different values. A CDN built for images reduces these to a few buckets. A general-purpose CDN in front of your own resizing service may need to be told to.
Several hostnames. The same file reachable at two domains is two cache entries, and the browser cache treats them as unrelated as well.
A storefront can have a long cache lifetime configured correctly and still serve most product images from origin, because the variants are so fragmented that few requests ever repeat. Lifetime and key design have to be right together.
Measuring whether the cache is working
The number to watch is the cache hit ratio: of all image requests reaching the CDN, the share answered from the edge without contacting origin. Most CDN dashboards report it, usually for all traffic together. Filter it to image requests if you can, because a healthy ratio on scripts and stylesheets can hide a poor one on images.
For a spot check, open a product page, find an image in your browser's network panel and read its response headers. Three things are worth looking at:
- The cache status header. The name varies by provider, such as
cf-cache-statusorx-cache, and the value is typically HIT or MISS. Load the page twice. A second MISS in a row means the image is not being stored at all. age. How many seconds the copy has been in the edge cache. If it never climbs past a few minutes on a popular product, something is evicting or bypassing it.cache-control. Check that it says what you intended. Ano-storeorprivatehere, often added by an application default, turns the CDN into an expensive proxy.
Read a hit ratio with the shape of your catalog in mind. A store with fifty products and steady traffic should see almost every image request served from the edge. A store with forty thousand SKUs will always have a long tail of products that are viewed rarely, and those images will have dropped out of the edge cache between visits. What matters is that the images on your highest-traffic pages are hits: the home page, top collections and best sellers.
The figures above are arithmetic, not a benchmark. They show why a hit ratio falls: traffic to one image gets spread across more variants, so each variant is requested less often and is less likely to be in the cache when the next shopper asks.
Caching and the first product image
On most product pages the main image is the Largest Contentful Paint element, so its delivery time is close to the page's headline speed metric. Caching affects that in a specific way: a cache hit removes the origin from the path. On a miss, the shopper waits for the CDN to fetch the original, resize it, encode it and then send it. On a hit, they wait for the send alone.
The practical consequence is that the first shopper after any change pays the full cost. After a bulk image update, a theme change that alters image widths, or a CDN purge, every product page is cold at once. Two habits reduce the damage:
- Purge narrowly. A "purge everything" button clears every image variant in the catalog. If one image is wrong, clear that one URL, or better, publish it at a new URL and purge nothing.
- Warm the pages that matter. After a large update, request your top collection and product pages yourself, or with a simple script that follows the sitemap, so the first real shopper gets a hit. Do this for the image widths your theme actually requests.
stale-while-revalidate helps here as well. It lets the CDN hand over the stored copy immediately when the lifetime has just expired, and refresh it in the background. For images at fixed URLs it removes the slow request that would otherwise land on whichever shopper arrives first after expiry. The rest of the hero-image picture, including preloading and priority hints, is covered in the product image LCP guide.
A caching policy you can write down
Most stores never state their caching rules, which is why the rules drift every time a theme or app changes. A written policy can be short. This one fits on a page and suits most catalogs:
- Every product image URL changes when the image changes. Use the platform's versioning, or a hash in the filename if you host images yourself. No overwriting in place.
- Versioned images are cached for a year and marked immutable, at the edge and in the browser.
- Anything at a fixed URL gets a short browser lifetime and a longer edge lifetime, with
stale-while-revalidate. - Image URLs come from one helper with a fixed list of widths and a fixed parameter order.
- Only width, format, quality and version count toward the cache key. Other query parameters are ignored for images.
- One hostname serves images.
- Purges are per URL. A full purge needs a reason and is followed by warming the top pages.
- Image cache hit ratio is checked monthly and after every theme release.
On a hosted platform, several of these are already true and your job is to avoid undoing them, mainly by not hardcoding image URLs and not requesting arbitrary sizes. On a custom or headless build, each line is a decision someone has to make and own.
The policy also makes image updates boring, which is the goal. When a retouched or reshot image replaces an old one, it goes up as a new file, the product points at it, and the next page load shows it everywhere. No one has to remember to clear anything.