Give every image a filename that contains a hash of its bytes, and serve it with Cache-Control: public, max-age=31536000, immutable. When the image changes, the name changes, so nothing ever needs revalidating. Keep no-cache plus an ETag for the few images whose URL must stay fixed: in my test that turned a 53,129-byte response into a 162-byte 304.
What do the three directives actually do?
From MDN's Cache-Control reference, read today:
-
max-age=N: the response stays fresh for N seconds. While fresh, the browser reuses it without asking. -
immutable: the response will not change while it is fresh, so the browser can skip revalidation, even on a reload. -
no-cache: the browser may store the response, but must check with the server before each reuse. It does not mean "do not cache". That isno-store.
31536000 is 365 days in seconds. Pick it because it is long, not because a spec asks for it.
Why does a hashed filename matter?
immutable is a promise that the bytes behind a URL never change. You can only keep that promise if the URL changes whenever the bytes do. A content hash in the name does exactly that:
/img/photo.avif <- the URL a person types /img/photo.1cfeebce02.avif <- the URL the HTML points at Re-encode the photo at another quality and the hash, and so the name, changes. The HTML points at the new name. Old visitors keep the old file until it ages out. Nobody sees a stale image, and nobody revalidates.
Note: Never put
immutableon an unhashed URL like/img/logo.png. If you ship a new logo under the same name, browsers that cached the old one will keep showing it for up to a year, and a reload will not fix it.
The two policies in one Node server
This server serves one AVIF two ways. The hashed path gets a year and immutable. The plain path gets no-cache and an ETag, and answers 304 Not Modified when the browser already holds that version.
const http = require('http'); const sharp = require('sharp'); const { createHash } = require('crypto'); (async () => { const src = 'packages/engine/tests/fixtures/photo.jpg'; const body = await sharp(src).avif({ quality: 50 }).toBuffer(); const digest = createHash('sha256').update(body); const hash = digest.copy().digest('hex').slice(0, 10); const etag = `"${digest.digest('base64url').slice(0, 16)}"`; const hashed = `/img/photo.${hash}.avif`; http.createServer((req, res) => { res.setHeader('Content-Type', 'image/avif'); if (req.url === hashed) { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } else if (req.url === '/img/photo.avif') { res.setHeader('Cache-Control', 'no-cache'); res.setHeader('ETag', etag); if (req.headers['if-none-match'] === etag) { res.statusCode = 304; return res.end(); } } else { res.statusCode = 404; return res.end(); } res.setHeader('Content-Length', body.length); res.end(body); }).listen(8080, () => console.log('hashed path', hashed)); })(); The ETag comes from the bytes too. Two servers behind a load balancer then hand out the same ETag for the same file, which a modification-time ETag cannot promise.
How many bytes does a revalidation cost?
I counted the bytes the client socket actually read, headers included, with req.socket.bytesRead:
{ status: 200, wireBytes: 53129 } { status: 304, wireBytes: 162 } The image body is 52,954 bytes (photo.jpg, 800 × 600, as AVIF at quality 50). The 304 carries no body, only the status line and headers.
| Policy | Repeat view while fresh | Repeat view after a change |
|---|---|---|
Hashed name + immutable | No request | New URL, full download |
Plain name + no-cache + ETag | 1 request, 162-byte 304 | 1 request, 53,129-byte 200 |
Plain name + long max-age, no hash | No request | Stale image until it expires |
The first row's "no request" comes from MDN's definition, not from my test: I ran Node's fetch, which has no HTTP cache, so I could measure the wire bytes but not a browser skipping the request. The 304 still costs a full round trip. On a page with many images, those small answers add up even though each is tiny.
What does a CDN do with these headers?
A CDN sits between the browser and your server and keeps its own copy. Most CDNs read the same Cache-Control header unless you tell them otherwise. Two things to check on yours:
-
s-maxagesets a separate lifetime for shared caches (the CDN) whilemax-agegoverns the browser. MDN documents it on the same page. -
Vary. If you serve AVIF or WebP from one URL by theAcceptheader, the response needsVary: Accept, or the CDN may hand a WebP to a browser that asked for JPEG. A hashed, format-specific name (photo.1cfeebce02.avif) avoids the problem: one URL, one format.
For a CDN, the hashed name also makes purging unnecessary. You never purge photo.1cfeebce02.avif; you stop linking to it.
Where should the hash come from?
From the encoded output, not the source. The same source encoded twice at different settings gives different bytes, and a cache must see them as different files. Compute the hash after the encode step of your build, then write the file under its hashed name and record the mapping for your templates. Images imported through a bundler often get hashed names already. User uploads and CMS images usually do not, so they need the same step in your upload pipeline.
If you are reworking those images anyway, the image optimization guide covers format and size choices before the cache headers come in.
How I measured
- Machine: Apple M1 Pro, macOS 15.7.3, on 2026-10-17.
- Node 24.22.0, sharp 0.35.4, libvips 8.18.6.
- File:
packages/engine/tests/fixtures/photo.jpg(800 × 600), encoded as AVIF quality 50 to 52,954 bytes. - Wire bytes from
req.socket.bytesReadwith a fresh connection per request (agent: false), against a localhttpserver.
What I did not test
I did not run a browser or a real CDN, so the "no request" row relies on MDN's definition of immutable. Local timings were too small to mean anything, so I left them out.
I build Blinkimg, an image compressor and converter with a developer API. I wrote this article with help from an AI assistant (Claude), which ran the code on my machine; every number in it comes from those runs.

