Skip to content
Focal
Blog
  • caching
  • http
  • performance

Caching transformed images with Cache-Control, immutable and per-format cache keys

Cache resized images safely with a long max-age on versioned URLs, immutable for browsers, and a per-format cache key when the format depends on Accept.

By Focal team 6 min read

A transformed image is expensive to produce once and cheap to serve many times, so the cache hit ratio drives its cost. Three details cause trouble with images. Lifetimes are long but the files behind them change. The immutable directive behaves differently across browsers. Automatic format selection puts two different files behind one URL. This post covers the headers that control each part, the browser support for each, and a set of headers for the common cases.

Two caches read your headers

A response passes through at least two caches. The browser has a private cache for one user. A content delivery network (CDN) has a shared cache for everyone. The Cache-Control header speaks to both, with a few directives that apply to one side only:

  • max-age=N keeps the response fresh for N seconds after the server generated it.
  • s-maxage=N applies to shared caches only and overrides max-age there. Browsers ignore it.
  • public allows a shared cache to store the response, even for a request that carried an Authorization header.

Cloudflare, as one example of a CDN, uses s-maxage as the edge lifetime and max-age as the browser lifetime when you send both.

Long lifetimes need URLs that change

A transformed image URL such as /800x0/products/chair.jpg depends on the source file at products/chair.jpg. If someone uploads a new photo to that same path, every cache keeps serving the old version until its lifetime runs out.

MDN describes the standard answer as the cache-busting pattern. You never change a file behind an existing URL. You publish new content at a new URL, and you give every URL a long lifetime. MDN’s example header for such assets is:

Cache-Control: max-age=31536000, immutable

That is one year. For product images, the pattern means a version or a content hash in the source path, such as chair-v2.jpg or chair.3f9a1c.jpg. When the photo changes, you upload a new file and update the reference in your catalogue data.

What immutable changes

RFC 8246 defines immutable. It tells clients that the server will not change the response while it is fresh. A client that honours it should not send a conditional revalidation request during that time, even when the user reloads the page. A force reload still revalidates.

Browser support is uneven. MDN’s compatibility data lists support in Firefox from version 49 and Safari from version 11. Chrome does not support the directive. Cloudflare’s documentation states that immutable has no effect on public caches like Cloudflare and only changes browser behaviour. In practice, the directive saves revalidation requests on reload for Firefox and Safari users. Send it only on URLs whose content never changes.

stale-while-revalidate for URLs that can change

Some image URLs cannot carry a version, such as a logo path that emails reference. For those, a shorter max-age with stale-while-revalidate keeps responses fast while still picking up changes. MDN’s example keeps a response fresh for seven days, then lets caches serve it stale for one more day while they revalidate in the background:

Cache-Control: max-age=604800, stale-while-revalidate=86400

MDN’s compatibility data lists support in Chrome from version 75, Firefox from 68 and Safari from 14.

The cache key decides what counts as the same image

RFC 9111 defines the cache key as, at a minimum, the request method and the target URI. For a URL-based image service, every parameter in the path is part of the key. The URLs /800x0/filters:quality(80)/chair.jpg and /800x0/filters:quality(81)/chair.jpg are two cache entries and two transformations, even though the two files look almost identical.

Keep the sizes and quality values in one place in your code, and generate every image URL from that list. A developer who types 801x0 by hand creates a new cache entry and a new transformation for every product.

Automatic format selection needs a per-format key

Some services pick the output format from the request’s Accept header. Chrome gets WebP and an older browser gets JPEG, both from the same URL. The URL alone no longer identifies the file, so the response must carry a Vary header:

Vary: Accept

RFC 9111 sets the rule for caches. A cache must not reuse a stored response for a new request unless every request header named in Vary matches the original request. Focal sends Vary: Accept whenever it picks the format from the Accept header. Next.js caches each format of an optimised image separately.

What goes wrong without it

A shared cache stores the WebP produced for a request from Chrome. A browser that does not list image/webp in its Accept header then requests the same URL. Without Vary, the cache returns the WebP, and that browser shows a broken image.

Why Vary alone is not the whole answer

Accept values differ between browsers and between versions. MDN lists different default image Accept strings for Chrome 121 and later, for Firefox 128 and later, and for Safari. A cache that keys on the exact string stores a separate copy for each one, even when they all resolve to WebP.

Some CDNs also ignore Vary by default. Cloudflare’s documentation states that it does not consider Vary values in caching decisions, except in three cases. The cases are its Cache Rules Vary setting, its Vary for Images feature, and Vary: accept-encoding. Vary for Images parses the Accept header, works out which image format the browser supports, and caches one variant per format. It is available on the Pro, Business and Enterprise plans.

Focal applies the same idea to its own cache. It picks the format from the Accept header before it looks up the cache, and it adds that format to the cache key.

A format in the URL avoids the problem

When the URL names the format, one URL maps to one file and Vary is unnecessary. The <picture> element pairs well with this approach, because the browser chooses the <source> by type and each source has its own URL.

Headers for three common cases

# Versioned path, format named in the URL
Cache-Control: public, max-age=31536000, immutable

# Versioned path, format chosen from the Accept header
Cache-Control: public, max-age=31536000, immutable
Vary: Accept

# Path that may change in place
Cache-Control: public, max-age=86400, stale-while-revalidate=604800

A long lifetime is a ceiling, not a guarantee

A one-year max-age does not keep a file in a CDN for a year. Cloudflare’s documentation explains that it evicts least recently used content before its lifetime expires. How long an object stays depends on its popularity and the size of the cache, and customers cannot configure it. Rarely viewed images will still miss now and then.

How Focal caches

Focal caches every result at the edge. When a URL names no format, browsers that accept WebP get WebP and the rest get the source format, and Focal caches each format separately. Focal has no cache purge. Treat your source paths as versioned, and upload a changed photo under a new file name. The Focal docs describe the URL format.

To check any image service, request the same URL twice with different Accept headers and compare the responses:

curl -sI -H "Accept: image/webp,*/*" "$IMAGE_URL" | grep -iE "content-type|cache-control|vary"
curl -sI -H "Accept: image/jpeg,*/*" "$IMAGE_URL" | grep -iE "content-type|cache-control|vary"

If the two content types differ, both responses should carry Vary: Accept. If they differ and carry no Vary header, a shared cache in front of that service can hand one browser the format meant for another.

Sources

More from the blog

All posts