
Cache-Control for Images: The Complete Guide to Image Caching Headers
How image cache-control headers work: max-age, s-maxage, immutable, stale-while-revalidate, and ETag revalidation. Copy-paste configs for nginx, Apache, Vercel, Netlify, and Express, plus the mistakes that keep your images from caching.
Images are usually the heaviest part of a page. They are also the safest thing on that page to cache aggressively. A JPEG or WebP file almost never changes its content at the same URL — when you update an image, you ship it under a new name. That property makes image caching far simpler than HTML or API caching, and yet most sites still send weak or missing cache-control headers for their images.
This guide explains how image cache-control headers work, which directives matter for images, and how to configure them on nginx, Apache, Vercel, Netlify, and Express. It also covers the testing workflow and the common mistakes that silently disable caching.
Why Images Deserve Aggressive Caching
A typical page sends 1–3 MB of images. If those bytes come from the browser cache or a CDN edge instead of your origin server, three things improve:
| Benefit | What changes |
|---|---|
| Load speed | Repeat visits skip the network entirely for cached images |
| Server cost | Origin bandwidth and CPU drop sharply as cache hit rates rise |
| Stability | Traffic spikes hit the CDN, not your origin |
The reason you can be aggressive is content immutability. Modern pipelines fingerprint assets during the build: hero-a1b2c3.webp. When the image content changes, the filename changes with it. The old URL keeps serving the old bytes forever, so there is no risk in telling every browser and CDN to store it for a year.
Unfingerprinted uploads (user avatars, CMS media libraries) need slightly more care, but even they can cache for weeks or months if you pair long lifetimes with versioned URLs or CDN purging.
Cache-Control Directives Explained
The Cache-Control response header controls how caches store and reuse a resource. These are the directives that matter for images:
max-age
max-age=N tells browsers the response is fresh for N seconds after it was fetched. During that window the browser uses its copy without contacting the server at all.
Cache-Control: max-age=86400
This is the core directive for browser-side image caching. One day (86400) is a reasonable floor for images; a year is better when the URL is fingerprinted.
s-maxage
s-maxage=N does the same job but applies only to shared caches: CDNs, reverse proxies, and corporate caches. When both are present, shared caches prefer s-maxage over max-age, so you can give CDNs a long lifetime while keeping browsers on a shorter one.
Cache-Control: max-age=3600, s-maxage=31536000
immutable
immutable tells the browser the resource will never change during its freshness lifetime. Without it, browsers still revalidate some assets when the user reloads the page (the “normal reload” behavior). With it, even a hard-ish reload skips the conditional request entirely.
It only makes sense together with a fingerprinted URL:
Cache-Control: max-age=31536000, immutable
For unfingerprinted uploads, leave immutable off — the URL might serve different bytes later.
public and private
public says any cache may store the response, including CDNs, even if the request carried an Authorization header. private restricts storage to the user’s browser only.
Images almost always want public. Use private only when an image is personalized per user and served behind authentication.
no-cache vs no-store
These two get confused constantly, and the confusion causes real damage to image caching:
no-cachemeans “store it, but revalidate before each use.” The cache keeps the bytes; it just checks with the server first usingETagorLast-Modified.no-storemeans “never save this anywhere.” Every request downloads the full body again.
Putting no-cache on images turns every view into a round trip. Putting no-store on images is almost always a mistake — it multiplies your bandwidth bill for no benefit. Neither directive belongs on ordinary site imagery.
Recommended Policies by Asset Type
Different image classes need different policies. Pick the row that matches how the asset gets its URL:
| Asset type | Header | Why |
|---|---|---|
Fingerprinted build output (logo.a1b2c3.webp) |
public, max-age=31536000, immutable |
Content never changes at this URL |
| Unfingerprinted uploads with versioned URLs | public, max-age=31536000 |
New version ships under a new query string or path |
| Unfingerprinted uploads without versioning | public, max-age=604800 plus ETag revalidation |
A week of freshness, then cheap revalidation |
| HTML pages hosting images | max-age=0, must-revalidate (or short max-age) |
Markup changes often; keep it fresh |
Two notes on the table:
- For unfingerprinted uploads, prefer adding URL versioning so you can raise the lifetime to a year. Versioning costs one template change; short max-age costs bandwidth forever.
- The HTML policy exists because the browser needs fresh markup to discover new image URLs. Never let aggressive image settings leak onto your HTML responses.
ETag and Last-Modified: The Revalidation Fallback
When a cached response goes stale, the browser does not necessarily redownload it. It sends a conditional request:
GET /images/hero.webp HTTP/1.1
If-None-Match: "a1b2c3d4"
If the file has not changed, the server answers 304 Not Modified with no body. The browser keeps its copy. This is revalidation, and it depends on two headers:
- ETag: an opaque identifier for the content version, like
"a1b2c3d4". Matched viaIf-None-Match. - Last-Modified: a timestamp of the last change. Matched via
If-Modified-Since.
Revalidation is much cheaper than a full download, but it is not free: the request still travels to the server, and on high-latency mobile connections that round trip delays rendering. Treat ETags as a safety net for URLs that might change — not as a substitute for long max-age on stable URLs.
One nginx-specific gotcha: by default nginx computes ETags from the file’s modification time and size, which works fine for static files. But if you run multiple servers behind a load balancer and files were deployed at slightly different times, ETags can differ between nodes and cause needless 200 responses. Either sync deployment timestamps or generate content-hash ETags yourself.
stale-while-revalidate for Images
stale-while-revalidate=N lets a cache serve a stale response while it fetches a fresh one in the background:
Cache-Control: max-age=86400, stale-while-revalidate=604800
With this header, a user who requests an image one week after its max-age expired still gets an instant response from cache. The CDN refreshes its copy asynchronously, so the next visitor sees updated content. The perceived latency of a cold-ish image drops to near zero.
Support is good in CDNs and proxies (Fastly, Cloudflare, Varnish) and solid in modern Chrome-based browsers. Firefox supports it too. Safari ignores it gracefully — those clients just fall back to normal revalidation. Because failure mode is “behaves like today,” adding it is low-risk.
Browser Caching vs CDN Edge Caching
Image caching happens in layers, and the same header drives all of them:
Browser memory/disk cache ← fastest, per-user
↑
CDN edge / reverse proxy ← shared, near the user
↑
Origin server ← source of truth
- Browser cache serves one user. Hit = zero network traffic.
- CDN edge cache serves many users in a region. Hit = no origin traffic, one short hop for the user.
- Origin only sees requests that miss everywhere.
s-maxage targets the middle layer; max-age targets the top. Some CDNs also honor their own settings (a default TTL or a surrogate key rule) when Cache-Control is absent — but relying on defaults means different providers behave differently. Set explicit image cache-control headers so every layer agrees.
Purging Strategies: Changing What a URL Serves
Long cache lifetimes create one obligation: when image content changes, the URL must change too. You have three practical options.
Path versioning (recommended)
Put the version inside the path:
/images/v2/hero.webp
/images/hero-2026-08-21.webp
Every part of the URL participates in the cache key, so every layer — browser, CDN, corporate proxy — treats the new path as a brand-new resource. No purge needed, ever.
Query string versioning — with a caveat
/images/hero.webp?v=2
Query strings work in browsers and in most modern CDNs, but they have a known weakness: some CDN configurations and older proxy tiers ignore the query string when computing the cache key. If such a cache already holds /images/hero.webp?v=1 under the key /images/hero.webp, your ?v=2 request can be served the stale v1 bytes. Some CDNs also treat each unique query string as a separate cache entry, which fragments your hit rate.
Before depending on query-string versioning, confirm your CDN’s cache-key rules. Path versioning avoids the question entirely.
Active purging
Most CDNs offer a purge API that invalidates cached URLs on demand. This works well, but it couples your deploy process to the CDN and adds an operation to forget. Prefer versioned URLs as the default mechanism and keep purging for emergencies (fixing a wrong image fast).
Service Worker Caching for Images
A service worker sits in front of the network inside the browser and can serve images from a CacheStorage bucket. This gives you offline support and instant repeat loads, independent of HTTP headers.
A minimal cache-first strategy for images:
const CACHE = 'images-v1';
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (!/\.(webp|avif|jpe?g|png|gif|svg)$/i.test(url.pathname)) return;
event.respondWith(
caches.open(CACHE).then(async (cache) => {
const hit = await cache.match(event.request);
if (hit) return hit;
const res = await fetch(event.request);
if (res.ok) cache.put(event.request, res.clone());
return res;
})
);
});
Three cautions:
- Version the cache name (
images-v1,images-v2) and delete old caches on activate, or users accumulate stale copies you cannot reach. - Cap what you cache. A photo gallery can fill
CacheStoragequickly; check quotas or limit entries. - The service worker overrides HTTP headers for requests it handles, so a bad strategy can mask correct server config. Keep the worker simple and let
Cache-Controldo the heavy lifting for everyone else.
Service workers shine for apps where images must render offline. For a standard content site, correct headers plus a CDN deliver most of the benefit with none of the maintenance.
Configuration Examples
Copy the block that matches your stack. Each example applies long-lived caching to common image extensions and leaves other routes alone.
nginx
location ~* \.(webp|avif|jpg|jpeg|png|gif|svg|ico)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
expires 1y sets Expires; the add_header line sets Cache-Control, which modern clients prefer. Apply this only to fingerprinted asset directories — for an uploads directory served at mutable URLs, drop immutable and shorten the lifetime:
location /uploads/ {
add_header Cache-Control "public, max-age=604800";
}
Apache (.htaccess)
Requires mod_headers and mod_expires:
<IfModule mod_headers.c>
<FilesMatch "\.(webp|avif|jpe?g|png|gif|svg|ico)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
Apache also emits ETag and Last-Modified for static files automatically, giving you the revalidation fallback out of the box.
Vercel (vercel.json)
{
"headers": [
{
"source": "/images/:path*.(webp|avif|jpg|jpeg|png|gif|svg)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
}
]
}
Note that Vercel’s build output already receives strong immutable headers for hashed framework assets; this block covers images you place in public/ yourself.
Netlify (_headers)
Create a _headers file in your publish directory:
/images/*
Cache-Control: public, max-age=31536000, immutable
/uploads/*
Cache-Control: public, max-age=604800
Netlify’s atomic deploys mean old deploy URLs keep serving old files, which pairs naturally with immutable caching on your main domain.
Express (Node)
const express = require('express');
const app = express();
// Fingerprinted assets: cache for a year
app.use('/images',
express.static('dist/images', {
immutable: true,
maxAge: '1y',
})
);
// Mutable uploads: one week + ETag revalidation
app.use('/uploads',
express.static('uploads', {
maxAge: '7d',
etag: true,
lastModified: true,
})
);
app.listen(3000);
express.static sets ETag and Last-Modified by default; the options above add the Cache-Control lifetime and the immutable flag.
How to Test Your Image Caching
Configuring headers without verifying them is guesswork. Two tools cover everything.
curl -I
Check exactly what the server returns:
curl -sI https://example.com/images/hero.webp
Look for:
HTTP/2 200
cache-control: public, max-age=31536000, immutable
etag: "a1b2c3d4"
last-modified: Wed, 19 Aug 2026 10:14:00 GMT
Then prove revalidation works:
curl -sI https://example.com/images/hero.webp \
-H 'If-None-Match: "a1b2c3d4"'
A 304 Not Modified status confirms the fallback path. Also test through your production domain (not localhost), because CDN-transformed headers only appear there.
Chrome DevTools
Open the Network tab, filter to images, and reload the page. The Size column tells you where each image came from:
| Size column shows | Meaning |
|---|---|
(memory cache) |
Served from RAM — typical within one page session |
(disk cache) |
Served from disk across sessions — max-age doing its job |
A size like 142 kB |
Downloaded from the network — a miss or revalidation |
Click an image and open the Headers tab to see the exact Cache-Control value the browser received. To test a second visit, hard-refresh once, then do a normal navigation and watch the Size column flip to (disk cache).
One subtlety: a normal reload marks all resources for revalidation even when they are fresh — unless the response was immutable. That is precisely why the immutable directive matters for fingerprinted images: it suppresses those pointless conditional requests on reload.
Common Mistakes
These are the errors we see most often when auditing image caching:
| Mistake | Symptom | Fix |
|---|---|---|
no-cache on images |
Conditional requests on every view | Long max-age on stable URLs |
Missing immutable |
Revalidation requests on every reload | Add it to fingerprinted assets |
| Cookie-heavy image requests | Cookie/Set-Cookie on image responses stops CDN caching |
Serve images from a cookie-free domain or strip cookies at the edge |
| Same policy for HTML and images | Stale pages, or timid image lifetimes | Separate location/path rules per asset class |
| Query-string versioning on a strict CDN | Users see old images after an update | Switch to path versioning |
| Relying on CDN default TTLs | Behavior changes when you switch providers | Send explicit Cache-Control from origin |
The cookie problem deserves a note. Many platforms attach session cookies to every request on the domain, including images. Strict CDNs forward those cookies and may bypass cache or fragment the cache key per user, collapsing your hit rate. The standard fixes: host images on a separate cookie-less domain or subdomain, or configure the CDN to ignore cookies for static paths.
Wrapping Up
Image caching rewards a small amount of setup with permanent savings. The recipe fits in four lines:
- Fingerprint image URLs wherever your pipeline allows.
- Serve them with
Cache-Control: public, max-age=31536000, immutable. - Give mutable uploads shorter lifetimes plus ETag revalidation, and version their URLs when content changes.
- Verify with
curl -Iand DevTools’ Size column, then watch your origin traffic fall.
Once headers are right, the next lever is reducing the bytes themselves. Pair this guide with our coverage of when you need an image CDN, image CDN comparison, and Core Web Vitals for images to close the loop on delivery and size.
If you want transformation and caching handled together, Sirv delivers images from edge caches with dynamic resizing built in — see the Sirv Media Viewer for galleries, zoom, and 360 spins, try AI-powered editing and background removal at Sirv Studio, or create a free account to test it on your own images.