# Cache-Control for Images: The Complete Guide to Image Caching Headers

> Image cache-control in practice: max-age, immutable, stale-while-revalidate and ETag, with copy-paste configs for nginx, Apache and Vercel.

Canonical page: https://www.imageguide.dev/guides/image-caching-headers/

Published: 2026-08-21. Updated: 2026-10-17.

Publisher: ImageGuide, published by Sirv. [Editorial method](https://www.imageguide.dev/about/#how-we-write).

## Quick answer

Cache images for a long time when their URLs change with their contents. Use versioned filenames for immutable assets and a revalidation policy for URLs that can change. Keep private images out of shared public caches; cache headers are delivery controls, not access authorization.

Reference for this answer: [MDN Cache-Control reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control).

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.

```http
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.

```http
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:

```http
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-cache` means "store it, but revalidate before each use." The cache keeps the bytes; it just checks with the server first using `ETag` or `Last-Modified`.
- `no-store` means "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:

1. 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.
2. 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:

```http
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 via `If-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:

```http
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:

```js
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:

1. **Version the cache name** (`images-v1`, `images-v2`) and delete old caches on activate, or users accumulate stale copies you cannot reach.
2. **Cap what you cache.** A photo gallery can fill `CacheStorage` quickly; check quotas or limit entries.
3. 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-Control` do 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

```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:

```nginx
location /uploads/ {
    add_header Cache-Control "public, max-age=604800";
}
```

### Apache (.htaccess)

Requires `mod_headers` and `mod_expires`:

```apache
<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)

```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)

```js
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:

```bash
curl -sI https://example.com/images/hero.webp
```

Look for:

```http
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:

```bash
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:

1. Fingerprint image URLs wherever your pipeline allows.
2. Serve them with `Cache-Control: public, max-age=31536000, immutable`.
3. Give mutable uploads shorter lifetimes plus ETag revalidation, and version their URLs when content changes.
4. Verify with `curl -I` and 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](/guides/signs-you-need-an-image-cdn), [image CDN comparison](/guides/image-cdn-comparison), and [Core Web Vitals for images](/guides/core-web-vitals-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](https://sirv.com/help/articles/sirv-media-viewer/) for galleries, zoom, and 360 spins, try AI-powered editing and background removal at [Sirv Studio](https://www.sirv.studio/), or [create a free account](https://my.sirv.com/#/signup) to test it on your own images.

## Sources

- [HTTP Caching (RFC 9111)](https://www.rfc-editor.org/rfc/rfc9111.html)
- [HTTP caching, MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching)
